Skip to content

Commit 4783ff6

Browse files
committed
add support for configuring instance reuse via CLI options
Signed-off-by: Joel Dice <[email protected]>
1 parent 9a37e13 commit 4783ff6

File tree

7 files changed

+218
-11
lines changed

7 files changed

+218
-11
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ spin-trigger = { path = "crates/trigger" }
7474
spin-trigger-http = { path = "crates/trigger-http" }
7575
spin-trigger-redis = { path = "crates/trigger-redis" }
7676
terminal = { path = "crates/terminal" }
77+
rand.workspace = true
7778

7879
[target.'cfg(target_os = "linux")'.dependencies]
7980
# This needs to be an explicit dependency to enable

crates/trigger-http/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ http-body-util = { workspace = true }
1717
hyper = { workspace = true }
1818
hyper-util = { workspace = true }
1919
pin-project-lite = { workspace = true }
20+
rand.workspace = true
2021
rustls = { workspace = true }
2122
rustls-pki-types = { workspace = true }
2223
serde = { workspace = true }

crates/trigger-http/src/lib.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,20 @@ mod wasip3;
1212

1313
use std::{
1414
error::Error,
15+
fmt::Display,
1516
net::{Ipv4Addr, SocketAddr, ToSocketAddrs},
1617
path::PathBuf,
18+
str::FromStr,
1719
sync::Arc,
20+
time::Duration,
1821
};
1922

2023
use anyhow::{bail, Context};
2124
use clap::Args;
25+
use rand::{
26+
distr::uniform::{SampleRange, SampleUniform},
27+
RngCore,
28+
};
2229
use serde::Deserialize;
2330
use spin_app::App;
2431
use spin_factors::RuntimeFactors;
@@ -31,6 +38,11 @@ pub use tls::TlsConfig;
3138

3239
pub(crate) use wasmtime_wasi_http::body::HyperIncomingBody as Body;
3340

41+
const DEFAULT_WASIP3_MAX_INSTANCE_REUSE_COUNT: usize = 128;
42+
const DEFAULT_WASIP3_MAX_INSTANCE_CONCURRENT_REUSE_COUNT: usize = 16;
43+
const DEFAULT_REQUEST_TIMEOUT: Option<Range<Duration>> = None;
44+
const DEFAULT_IDLE_INSTANCE_TIMEOUT: Range<Duration> = Range::Value(Duration::from_secs(1));
45+
3446
/// A [`spin_trigger::TriggerApp`] for the HTTP trigger.
3547
pub(crate) type TriggerApp<F> = spin_trigger::TriggerApp<HttpTrigger, F>;
3648

@@ -58,6 +70,59 @@ pub struct CliArgs {
5870

5971
#[clap(long = "find-free-port")]
6072
pub find_free_port: bool,
73+
74+
/// Maximum number of requests to send to a single component instance before
75+
/// dropping it.
76+
///
77+
/// This defaults to 1 for WASIp2 components and 128 for WASIp3 components.
78+
/// As of this writing, setting it to more than 1 will have no effect for
79+
/// WASIp2 components, but that may change in the future.
80+
///
81+
/// This may be specified either as an integer value or as a range,
82+
/// e.g. 1..8. If it's a range, a number will be selected from that range
83+
/// at random for each new instance.
84+
#[clap(long, value_parser = parse_usize_range)]
85+
max_instance_reuse_count: Option<Range<usize>>,
86+
87+
/// Maximum number of concurrent requests to send to a single component
88+
/// instance.
89+
///
90+
/// This defaults to 1 for WASIp2 components and 16 for WASIp3 components.
91+
/// Note that setting it to more than 1 will have no effect for WASIp2
92+
/// components since they cannot be called concurrently.
93+
///
94+
/// This may be specified either as an integer value or as a range,
95+
/// e.g. 1..8. If it's a range, a number will be selected from that range
96+
/// at random for each new instance.
97+
#[clap(long, value_parser = parse_usize_range)]
98+
max_instance_concurrent_reuse_count: Option<Range<usize>>,
99+
100+
/// Request timeout to enforce.
101+
///
102+
/// As of this writing, this only affects WASIp3 components.
103+
///
104+
/// A number with no suffix or with an `s` suffix is interpreted as seconds;
105+
/// other accepted suffixes include `ms` (milliseconds), `us` or `μs`
106+
/// (microseconds), and `ns` (nanoseconds).
107+
///
108+
/// This may be specified either as a single time value or as a range,
109+
/// e.g. 1..8s. If it's a range, a value will be selected from that range
110+
/// at random for each new instance.
111+
#[clap(long, value_parser = parse_duration_range)]
112+
request_timeout: Option<Range<Duration>>,
113+
114+
/// Time to hold an idle component instance for possible reuse before
115+
/// dropping it.
116+
///
117+
/// A number with no suffix or with an `s` suffix is interpreted as seconds;
118+
/// other accepted suffixes include `ms` (milliseconds), `us` or `μs`
119+
/// (microseconds), and `ns` (nanoseconds).
120+
///
121+
/// This may be specified either as a single time value or as a range,
122+
/// e.g. 1..8s. If it's a range, a value will be selected from that range
123+
/// at random for each new instance.
124+
#[clap(long, default_value = "1s", value_parser = parse_duration_range)]
125+
idle_instance_timeout: Range<Duration>,
61126
}
62127

63128
impl CliArgs {
@@ -73,6 +138,112 @@ impl CliArgs {
73138
}
74139
}
75140

141+
#[derive(Copy, Clone)]
142+
enum Range<T> {
143+
Value(T),
144+
Bounds(T, T),
145+
}
146+
147+
impl<T> Range<T> {
148+
fn map<V>(self, fun: impl Fn(T) -> V) -> Range<V> {
149+
match self {
150+
Self::Value(v) => Range::Value(fun(v)),
151+
Self::Bounds(a, b) => Range::Bounds(fun(a), fun(b)),
152+
}
153+
}
154+
}
155+
156+
impl<T: SampleUniform + PartialOrd> SampleRange<T> for Range<T> {
157+
fn sample_single<R: RngCore + ?Sized>(
158+
self,
159+
rng: &mut R,
160+
) -> Result<T, rand::distr::uniform::Error> {
161+
match self {
162+
Self::Value(v) => Ok(v),
163+
Self::Bounds(a, b) => (a..b).sample_single(rng),
164+
}
165+
}
166+
167+
fn is_empty(&self) -> bool {
168+
match self {
169+
Self::Value(_) => false,
170+
Self::Bounds(a, b) => (a..b).is_empty(),
171+
}
172+
}
173+
}
174+
175+
fn parse_range<T: FromStr>(s: &str) -> Result<Range<T>, String>
176+
where
177+
T::Err: Display,
178+
{
179+
let error = |e| format!("expected integer or range; got {s:?}; {e}");
180+
if let Some((start, end)) = s.split_once("..") {
181+
Ok(Range::Bounds(
182+
start.parse().map_err(error)?,
183+
end.parse().map_err(error)?,
184+
))
185+
} else {
186+
Ok(Range::Value(s.parse().map_err(error)?))
187+
}
188+
}
189+
190+
fn parse_usize_range(s: &str) -> Result<Range<usize>, String> {
191+
parse_range(s)
192+
}
193+
194+
struct ParsedDuration(Duration);
195+
196+
impl FromStr for ParsedDuration {
197+
type Err = String;
198+
199+
fn from_str(s: &str) -> Result<Self, Self::Err> {
200+
let error = |e| {
201+
format!("expected integer suffixed by `s`, `ms`, `us`, `μs`, or `ns`; got {s:?}; {e}")
202+
};
203+
Ok(Self(match s.parse() {
204+
Ok(val) => Duration::from_secs(val),
205+
Err(err) => {
206+
if let Some(num) = s.strip_suffix("s") {
207+
Duration::from_secs(num.parse().map_err(error)?)
208+
} else if let Some(num) = s.strip_suffix("ms") {
209+
Duration::from_millis(num.parse().map_err(error)?)
210+
} else if let Some(num) = s.strip_suffix("us").or(s.strip_suffix("μs")) {
211+
Duration::from_micros(num.parse().map_err(error)?)
212+
} else if let Some(num) = s.strip_suffix("ns") {
213+
Duration::from_nanos(num.parse().map_err(error)?)
214+
} else {
215+
return Err(error(err));
216+
}
217+
}
218+
}))
219+
}
220+
}
221+
222+
fn parse_duration_range(s: &str) -> Result<Range<Duration>, String> {
223+
parse_range::<ParsedDuration>(s).map(|v| v.map(|v| v.0))
224+
}
225+
226+
#[derive(Clone, Copy)]
227+
pub struct InstanceReuseConfig {
228+
max_instance_reuse_count: Range<usize>,
229+
max_instance_concurrent_reuse_count: Range<usize>,
230+
request_timeout: Option<Range<Duration>>,
231+
idle_instance_timeout: Range<Duration>,
232+
}
233+
234+
impl Default for InstanceReuseConfig {
235+
fn default() -> Self {
236+
Self {
237+
max_instance_reuse_count: Range::Value(DEFAULT_WASIP3_MAX_INSTANCE_REUSE_COUNT),
238+
max_instance_concurrent_reuse_count: Range::Value(
239+
DEFAULT_WASIP3_MAX_INSTANCE_CONCURRENT_REUSE_COUNT,
240+
),
241+
request_timeout: DEFAULT_REQUEST_TIMEOUT,
242+
idle_instance_timeout: DEFAULT_IDLE_INSTANCE_TIMEOUT,
243+
}
244+
}
245+
}
246+
76247
/// The Spin HTTP trigger.
77248
pub struct HttpTrigger {
78249
/// The address the server should listen on.
@@ -83,6 +254,7 @@ pub struct HttpTrigger {
83254
tls_config: Option<TlsConfig>,
84255
find_free_port: bool,
85256
http1_max_buf_size: Option<usize>,
257+
reuse_config: InstanceReuseConfig,
86258
}
87259

88260
impl<F: RuntimeFactors> Trigger<F> for HttpTrigger {
@@ -94,13 +266,26 @@ impl<F: RuntimeFactors> Trigger<F> for HttpTrigger {
94266
fn new(cli_args: Self::CliArgs, app: &spin_app::App) -> anyhow::Result<Self> {
95267
let find_free_port = cli_args.find_free_port;
96268
let http1_max_buf_size = cli_args.http1_max_buf_size;
269+
let reuse_config = InstanceReuseConfig {
270+
max_instance_reuse_count: cli_args
271+
.max_instance_reuse_count
272+
.unwrap_or(Range::Value(DEFAULT_WASIP3_MAX_INSTANCE_REUSE_COUNT)),
273+
max_instance_concurrent_reuse_count: cli_args
274+
.max_instance_concurrent_reuse_count
275+
.unwrap_or(Range::Value(
276+
DEFAULT_WASIP3_MAX_INSTANCE_CONCURRENT_REUSE_COUNT,
277+
)),
278+
request_timeout: cli_args.request_timeout,
279+
idle_instance_timeout: cli_args.idle_instance_timeout,
280+
};
97281

98282
Self::new(
99283
app,
100284
cli_args.address,
101285
cli_args.into_tls_config(),
102286
find_free_port,
103287
http1_max_buf_size,
288+
reuse_config,
104289
)
105290
}
106291

@@ -125,6 +310,7 @@ impl HttpTrigger {
125310
tls_config: Option<TlsConfig>,
126311
find_free_port: bool,
127312
http1_max_buf_size: Option<usize>,
313+
reuse_config: InstanceReuseConfig,
128314
) -> anyhow::Result<Self> {
129315
Self::validate_app(app)?;
130316

@@ -133,6 +319,7 @@ impl HttpTrigger {
133319
tls_config,
134320
find_free_port,
135321
http1_max_buf_size,
322+
reuse_config,
136323
})
137324
}
138325

@@ -146,13 +333,15 @@ impl HttpTrigger {
146333
tls_config,
147334
find_free_port,
148335
http1_max_buf_size,
336+
reuse_config,
149337
} = self;
150338
let server = Arc::new(HttpServer::new(
151339
listen_addr,
152340
tls_config,
153341
find_free_port,
154342
trigger_app,
155343
http1_max_buf_size,
344+
reuse_config,
156345
)?);
157346
Ok(server)
158347
}

crates/trigger-http/src/server.rs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use hyper_util::{
2121
rt::{TokioExecutor, TokioIo},
2222
server::conn::auto::Builder,
2323
};
24+
use rand::Rng;
2425
use spin_app::{APP_DESCRIPTION_KEY, APP_NAME_KEY};
2526
use spin_factor_outbound_http::{OutboundHttpFactor, SelfRequestOrigin};
2627
use spin_factors::RuntimeFactors;
@@ -50,7 +51,7 @@ use crate::{
5051
wagi::WagiHttpExecutor,
5152
wasi::WasiHttpExecutor,
5253
wasip3::Wasip3HttpExecutor,
53-
Body, NotFoundRouteKind, TlsConfig, TriggerApp, TriggerInstanceBuilder,
54+
Body, InstanceReuseConfig, NotFoundRouteKind, TlsConfig, TriggerApp, TriggerInstanceBuilder,
5455
};
5556

5657
pub const MAX_RETRIES: u16 = 10;
@@ -83,6 +84,7 @@ impl<F: RuntimeFactors> HttpServer<F> {
8384
find_free_port: bool,
8485
trigger_app: TriggerApp<F>,
8586
http1_max_buf_size: Option<usize>,
87+
reuse_config: InstanceReuseConfig,
8688
) -> anyhow::Result<Self> {
8789
// This needs to be a vec before building the router to handle duplicate routes
8890
let component_trigger_configs = trigger_app
@@ -135,6 +137,7 @@ impl<F: RuntimeFactors> HttpServer<F> {
135137
&trigger_app,
136138
component,
137139
&trigger_config.executor,
140+
reuse_config,
138141
)
139142
.map(|ht| (component.clone(), ht)),
140143
),
@@ -157,6 +160,7 @@ impl<F: RuntimeFactors> HttpServer<F> {
157160
trigger_app: &Arc<TriggerApp<F>>,
158161
component_id: &str,
159162
executor: &Option<HttpExecutorType>,
163+
reuse_config: InstanceReuseConfig,
160164
) -> anyhow::Result<HandlerType<HttpHandlerState<F>>> {
161165
let pre = trigger_app.get_instance_pre(component_id)?;
162166
let handler_type = match executor {
@@ -166,6 +170,7 @@ impl<F: RuntimeFactors> HttpServer<F> {
166170
HttpHandlerState {
167171
trigger_app: trigger_app.clone(),
168172
component_id: component_id.into(),
173+
reuse_config,
169174
},
170175
)?;
171176
handler_type.validate_executor(executor)?;
@@ -636,6 +641,7 @@ pub(crate) trait HttpExecutor {
636641
pub(crate) struct HttpHandlerState<F: RuntimeFactors> {
637642
trigger_app: Arc<TriggerApp<F>>,
638643
component_id: String,
644+
reuse_config: InstanceReuseConfig,
639645
}
640646

641647
impl<F: RuntimeFactors> HandlerState for HttpHandlerState<F> {
@@ -653,23 +659,22 @@ impl<F: RuntimeFactors> HandlerState for HttpHandlerState<F> {
653659
}
654660

655661
fn request_timeout(&self) -> Duration {
656-
// TODO: Make this configurable
657-
Duration::MAX
662+
self.reuse_config
663+
.request_timeout
664+
.map(|range| rand::rng().random_range(range))
665+
.unwrap_or(Duration::MAX)
658666
}
659667

660668
fn idle_instance_timeout(&self) -> Duration {
661-
// TODO: Make this configurable
662-
Duration::from_secs(1)
669+
rand::rng().random_range(self.reuse_config.idle_instance_timeout)
663670
}
664671

665672
fn max_instance_reuse_count(&self) -> usize {
666-
// TODO: Make this configurable
667-
128
673+
rand::rng().random_range(self.reuse_config.max_instance_reuse_count)
668674
}
669675

670676
fn max_instance_concurrent_reuse_count(&self) -> usize {
671-
// TODO: Make this configurable
672-
16
677+
rand::rng().random_range(self.reuse_config.max_instance_concurrent_reuse_count)
673678
}
674679

675680
fn handle_worker_error(&self, error: anyhow::Error) {

0 commit comments

Comments
 (0)