Skip to content

Commit a905347

Browse files
committed
fix(metrics): hold back cold-start invocation metric until durable tag is known
In On-Demand mode, the Extensions API's `/next` poll (driving on_invoke_event) consistently beats the buffered Telemetry API's platform.initStart (which sets the durable_function tag), so the first invocation's aws.lambda.enhanced.invocations metric almost always missed durable_function:true. Hold the sandbox's first invocation metric in a pending field and flush it once platform.initStart has set the durable tag, with fallbacks on the next invoke or shutdown so the metric is never dropped if initStart never arrives.
1 parent d5489b7 commit a905347

1 file changed

Lines changed: 118 additions & 2 deletions

File tree

bottlecap/src/lifecycle/invocation/processor.rs

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ pub struct Processor {
111111
/// on `platform.report`. This flag ensures whichever event arrives first wins and the other is skipped,
112112
/// preventing double counting.
113113
init_duration_metric_emitted: bool,
114+
/// Timestamp of the invocation metric for the sandbox's first (cold start) invocation in
115+
/// On-Demand mode, held back from `enhanced_metrics` until `platform.initStart` is processed.
116+
///
117+
/// The Extensions API's `/next` poll (which drives `on_invoke_event`) has no artificial
118+
/// delay, while `platform.initStart` is delivered through the buffered Telemetry API. In
119+
/// On-Demand mode the Invoke event consistently reaches the processor first, so emitting the
120+
/// invocation metric immediately would race `set_durable_function_tag` and miss the
121+
/// `durable_function:true` tag on invocation #1. Holding the metric here until
122+
/// `on_platform_init_start` (or the next invocation, or shutdown) flushes it ensures the tag
123+
/// is applied before the metric is counted.
124+
pending_first_invocation_metric: Option<i64>,
114125
}
115126

116127
impl Processor {
@@ -154,12 +165,24 @@ impl Processor {
154165
durable_context_tx,
155166
restore_time: None,
156167
init_duration_metric_emitted: false,
168+
pending_first_invocation_metric: None,
157169
}
158170
}
159171

160172
/// Given a `request_id`, creates the context and adds the enhanced metric offsets to the context buffer.
161173
///
162174
pub fn on_invoke_event(&mut self, request_id: String) {
175+
// Flush any invocation metric held back from a previous cold start. This is a fallback
176+
// for the case where `platform.initStart` never arrived (or arrived very late) before
177+
// this next invocation; see `pending_first_invocation_metric` for why it's held back.
178+
self.flush_pending_first_invocation_metric();
179+
180+
// In On-Demand mode, the very first invocation of a cold sandbox races
181+
// `platform.initStart` (see `pending_first_invocation_metric`); hold its invocation
182+
// metric back instead of emitting it inline below.
183+
let is_on_demand_cold_start =
184+
!self.aws_config.is_managed_instance_mode() && self.context_buffer.is_empty();
185+
163186
// In Managed Instance mode, if awaiting the first invocation after init, find and update the empty context created on init start
164187
if self.aws_config.is_managed_instance_mode() && self.awaiting_first_invocation {
165188
if self
@@ -224,8 +247,13 @@ impl Processor {
224247
.add_enhanced_metric_data(&request_id, enhanced_metric_offsets);
225248
}
226249

227-
// Increment the invocation metric
228-
self.enhanced_metrics.increment_invocation_metric(timestamp);
250+
// Increment the invocation metric, unless it's an On-Demand cold start, in which case
251+
// it's held back until `platform.initStart` (or a fallback) flushes it.
252+
if is_on_demand_cold_start {
253+
self.pending_first_invocation_metric = Some(timestamp);
254+
} else {
255+
self.enhanced_metrics.increment_invocation_metric(timestamp);
256+
}
229257
self.enhanced_metrics.set_invoked_received();
230258

231259
// Both modes: check for buffered UniversalInstrumentationStart by request_id first.
@@ -257,6 +285,14 @@ impl Processor {
257285
}
258286
}
259287

288+
/// Emits the invocation metric held back by `on_invoke_event` for an On-Demand cold start,
289+
/// if one is pending. See `pending_first_invocation_metric` for why it's held back.
290+
fn flush_pending_first_invocation_metric(&mut self) {
291+
if let Some(timestamp) = self.pending_first_invocation_metric.take() {
292+
self.enhanced_metrics.increment_invocation_metric(timestamp);
293+
}
294+
}
295+
260296
/// On the first invocation, determine if it's a cold start or proactive init.
261297
///
262298
/// For every other invocation, it will always be warm start.
@@ -339,6 +375,10 @@ impl Processor {
339375
{
340376
self.enhanced_metrics.set_durable_function_tag();
341377
}
378+
// Flush the held-back invocation metric now that the durable_function tag (if any) has
379+
// been set, so invocation #1 is counted with the correct tag.
380+
self.flush_pending_first_invocation_metric();
381+
342382
let start_time: i64 = SystemTime::from(time)
343383
.duration_since(UNIX_EPOCH)
344384
.expect("time went backwards")
@@ -990,6 +1030,10 @@ impl Processor {
9901030
}
9911031

9921032
pub fn on_shutdown_event(&mut self) {
1033+
// Don't drop the invocation metric if the sandbox shuts down before
1034+
// `platform.initStart` (or a second invocation) had a chance to flush it.
1035+
self.flush_pending_first_invocation_metric();
1036+
9931037
let now = std::time::SystemTime::now()
9941038
.duration_since(std::time::UNIX_EPOCH)
9951039
.expect("unable to poll clock, unrecoverable")
@@ -2095,6 +2139,78 @@ mod tests {
20952139
);
20962140
}
20972141

2142+
#[tokio::test]
2143+
async fn test_on_invoke_event_cold_start_holds_invocation_metric_until_init_start() {
2144+
let mut processor = setup();
2145+
2146+
// Simulate the On-Demand race: the Invoke event reaches the processor before
2147+
// `platform.initStart`. The invocation metric must not be emitted yet.
2148+
processor.on_invoke_event("req-1".to_string());
2149+
assert!(
2150+
processor.pending_first_invocation_metric.is_some(),
2151+
"Expected the cold start invocation metric to be held back"
2152+
);
2153+
2154+
let now: i64 = std::time::UNIX_EPOCH
2155+
.elapsed()
2156+
.expect("unable to poll clock, unrecoverable")
2157+
.as_secs()
2158+
.try_into()
2159+
.unwrap_or_default();
2160+
2161+
// `platform.initStart` arrives afterwards and reports a durable runtime.
2162+
processor.on_platform_init_start(
2163+
Utc::now(),
2164+
Some("python:3.14.DurableFunction.v6".to_string()),
2165+
);
2166+
2167+
assert!(
2168+
processor.pending_first_invocation_metric.is_none(),
2169+
"Expected the held-back invocation metric to be flushed by on_platform_init_start"
2170+
);
2171+
2172+
let runtime = processor
2173+
.runtime
2174+
.clone()
2175+
.expect("runtime should have been resolved by on_invoke_event");
2176+
let ts = (now / 10) * 10;
2177+
let expected_tags = dogstatsd::metric::SortedTags::parse(&format!(
2178+
"cold_start:true,durable_function:true,runtime:{runtime}"
2179+
))
2180+
.ok();
2181+
let entry = processor
2182+
.enhanced_metrics
2183+
.aggr_handle
2184+
.get_entry_by_id(
2185+
crate::metrics::enhanced::constants::INVOCATIONS_METRIC.into(),
2186+
expected_tags,
2187+
ts,
2188+
)
2189+
.await
2190+
.unwrap();
2191+
assert!(
2192+
entry.is_some(),
2193+
"Expected the flushed invocation metric to carry the durable_function:true tag"
2194+
);
2195+
}
2196+
2197+
#[tokio::test]
2198+
async fn test_on_invoke_event_flushes_pending_metric_if_init_start_never_arrives() {
2199+
let mut processor = setup();
2200+
2201+
// First invocation of a cold sandbox: metric held back.
2202+
processor.on_invoke_event("req-1".to_string());
2203+
assert!(processor.pending_first_invocation_metric.is_some());
2204+
2205+
// A second invocation arrives without platform.initStart ever showing up. The held-back
2206+
// metric from req-1 must still be flushed (as a fallback), not lost.
2207+
processor.on_invoke_event("req-2".to_string());
2208+
assert!(
2209+
processor.pending_first_invocation_metric.is_none(),
2210+
"Expected the pending metric to be flushed as a fallback on the next invocation"
2211+
);
2212+
}
2213+
20982214
#[tokio::test]
20992215
async fn test_init_duration_emitted_from_init_report() {
21002216
let mut processor = setup();

0 commit comments

Comments
 (0)