Skip to content

Commit 8e5510b

Browse files
authored
Track scheduled function delay (#5592)
## Summary fixes: clockworklabs/SpacetimeDBPrivate#3441. It chooses an arbitrary threshold of `50ms`, if a scheduled function starts more than that threshold after its expected time, we emit a warning and record a metric. Note that, metric does not necessarily tells If something is wrong with Host as drift can also happen due to module's previous schedule function taking long time to finish.
1 parent 91946bc commit 8e5510b

3 files changed

Lines changed: 112 additions & 5 deletions

File tree

crates/core/src/host/module_host.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use indexmap::IndexSet;
3636
use itertools::Itertools;
3737
use parking_lot::Mutex;
3838
use prometheus::{Histogram, HistogramTimer, IntGauge};
39+
use rustc_hash::FxHashMap;
3940
use scopeguard::ScopeGuard;
4041
use smallvec::SmallVec;
4142
use spacetimedb_auth::identity::ConnectionAuthCtx;
@@ -254,6 +255,45 @@ pub struct ModuleMetrics {
254255
pub request_round_trip_subscribe: Histogram,
255256
pub request_round_trip_unsubscribe: Histogram,
256257
pub request_round_trip_sql: Histogram,
258+
scheduled_function_delay: ScheduledFunctionDelayMetrics,
259+
}
260+
261+
#[derive(Debug)]
262+
struct ScheduledFunctionDelayMetrics {
263+
database_identity: Identity,
264+
metrics_by_function: Mutex<FxHashMap<Box<str>, Histogram>>,
265+
}
266+
267+
impl ScheduledFunctionDelayMetrics {
268+
fn new(database_identity: &Identity) -> Self {
269+
Self {
270+
database_identity: *database_identity,
271+
metrics_by_function: Mutex::new(FxHashMap::default()),
272+
}
273+
}
274+
275+
fn observe(&self, function_name: &str, delay: Duration) {
276+
let mut metrics_by_function = self.metrics_by_function.lock();
277+
let metric = metrics_by_function
278+
.entry(Box::<str>::from(function_name))
279+
.or_insert_with(|| {
280+
WORKER_METRICS
281+
.scheduled_function_delay
282+
.with_label_values(&self.database_identity, function_name)
283+
});
284+
metric.observe(delay.as_secs_f64());
285+
}
286+
}
287+
288+
impl Drop for ScheduledFunctionDelayMetrics {
289+
fn drop(&mut self) {
290+
let metrics_by_function = std::mem::take(self.metrics_by_function.get_mut());
291+
for function_name in metrics_by_function.keys() {
292+
let _ = WORKER_METRICS
293+
.scheduled_function_delay
294+
.remove_label_values(&self.database_identity, function_name);
295+
}
296+
}
257297
}
258298

259299
impl ModuleMetrics {
@@ -272,15 +312,21 @@ impl ModuleMetrics {
272312
let request_round_trip_sql = WORKER_METRICS
273313
.request_round_trip
274314
.with_label_values(&WorkloadType::Sql, db, "");
315+
let scheduled_function_delay = ScheduledFunctionDelayMetrics::new(db);
275316
Self {
276317
connected_clients,
277318
ws_clients_spawned,
278319
ws_clients_aborted,
279320
request_round_trip_subscribe,
280321
request_round_trip_unsubscribe,
281322
request_round_trip_sql,
323+
scheduled_function_delay,
282324
}
283325
}
326+
327+
pub(in crate::host) fn observe_scheduled_function_delay(&self, function_name: &str, delay: Duration) {
328+
self.scheduled_function_delay.observe(function_name, delay);
329+
}
284330
}
285331

286332
impl ModuleInfo {

crates/core/src/host/scheduler.rs

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ const MAX_SCHEDULE_DELAY: Duration = Duration::from_millis(
190190
(1 << (6 * 6)) - 1,
191191
);
192192

193+
/// Warn when a scheduled function starts more than this long after it was due.
194+
const SCHEDULED_FUNCTION_DELAY_WARNING_THRESHOLD: Duration = Duration::from_millis(30);
195+
193196
#[derive(thiserror::Error, Debug)]
194197
pub enum ScheduleError {
195198
#[error("Unable to schedule with long delay at {0:?}")]
@@ -447,8 +450,9 @@ struct Reschedule {
447450
enum ScheduledProcedureStep {
448451
Done(CallScheduledFunctionResult, bool),
449452
Procedure {
450-
params: CallProcedureParams,
453+
params: Box<CallProcedureParams>,
451454
reschedule: Option<Reschedule>,
455+
delay: Option<(Arc<str>, Duration)>,
452456
},
453457
}
454458

@@ -465,9 +469,17 @@ pub(super) async fn call_scheduled_procedure(
465469
// even though it has been already moved during `delete_scheduled_function_row` call.
466470
match next_step {
467471
ScheduledProcedureStep::Done(result, trapped) => (result, trapped),
468-
ScheduledProcedureStep::Procedure { params, reschedule } => {
472+
ScheduledProcedureStep::Procedure {
473+
params,
474+
reschedule,
475+
delay,
476+
} => {
477+
if let Some((function_name, delay)) = delay.as_ref() {
478+
record_scheduled_function_delay(module_info, function_name, *delay);
479+
}
480+
469481
// Execute the procedure. See above for commentary on `catch_unwind()`.
470-
let result = panic::AssertUnwindSafe(inst_common.call_procedure(params, inst))
482+
let result = panic::AssertUnwindSafe(inst_common.call_procedure(*params, inst))
471483
.catch_unwind()
472484
.await;
473485

@@ -504,6 +516,7 @@ fn prepare_scheduled_procedure_call(
504516
inst: &mut impl WasmInstance,
505517
) -> ScheduledProcedureStep {
506518
let ScheduledFunctionParams(item) = params;
519+
let delay = scheduled_function_delay_context_for_item(&item);
507520
let id = scheduled_item_id(&item);
508521
let db = &**module_info.relational_db();
509522
let tx = db.begin_mut_tx(IsolationLevel::Serializable, Workload::Internal);
@@ -530,7 +543,13 @@ fn prepare_scheduled_procedure_call(
530543
let reschedule = id.and_then(|id| {
531544
delete_scheduled_function_row(module_info, db, id, Some(tx), (timestamp, instant), inst_common, inst)
532545
});
533-
ScheduledProcedureStep::Procedure { params, reschedule }
546+
let delay =
547+
delay.map(|(function_name, requested_at)| (function_name, scheduled_function_delay(timestamp, requested_at)));
548+
ScheduledProcedureStep::Procedure {
549+
params: Box::new(params),
550+
reschedule,
551+
delay,
552+
}
534553
}
535554

536555
fn call_scheduled_reducer_until_done(
@@ -540,6 +559,7 @@ fn call_scheduled_reducer_until_done(
540559
inst: &mut impl WasmInstance,
541560
) -> (CallScheduledFunctionResult, bool) {
542561
let ScheduledFunctionParams(item) = params;
562+
let delay = scheduled_function_delay_context_for_item(&item);
543563
let id = scheduled_item_id(&item);
544564
let db = &**module_info.relational_db();
545565
let tx = db.begin_mut_tx(IsolationLevel::Serializable, Workload::Internal);
@@ -561,6 +581,10 @@ fn call_scheduled_reducer_until_done(
561581
}
562582
};
563583

584+
if let Some((function_name, requested_at)) = delay {
585+
let delay = scheduled_function_delay(timestamp, requested_at);
586+
record_scheduled_function_delay(module_info, &function_name, delay);
587+
}
564588
call_scheduled_reducer_with_tx(module_info, db, id, tx, (timestamp, instant), params, inst_common, inst)
565589
}
566590

@@ -571,6 +595,35 @@ fn scheduled_item_id(item: &QueueItem) -> Option<ScheduledFunctionId> {
571595
}
572596
}
573597

598+
fn scheduled_function_delay_context_for_item(item: &QueueItem) -> Option<(Arc<str>, Timestamp)> {
599+
match item {
600+
QueueItem::Id { function_name, at, .. } => Some((function_name.clone(), *at)),
601+
QueueItem::VolatileNonatomicImmediate { .. } => None,
602+
}
603+
}
604+
605+
fn record_scheduled_function_delay(module_info: &ModuleInfo, function_name: &str, delay: Duration) {
606+
module_info
607+
.metrics
608+
.observe_scheduled_function_delay(function_name, delay);
609+
610+
if delay <= SCHEDULED_FUNCTION_DELAY_WARNING_THRESHOLD {
611+
return;
612+
}
613+
614+
log::warn!(
615+
"scheduled function `{}` for database {} is delayed by {:.3}s, exceeding the {:.3}s threshold",
616+
function_name,
617+
module_info.database_identity,
618+
delay.as_secs_f64(),
619+
SCHEDULED_FUNCTION_DELAY_WARNING_THRESHOLD.as_secs_f64(),
620+
);
621+
}
622+
623+
fn scheduled_function_delay(actual: Timestamp, requested: Timestamp) -> Duration {
624+
actual.duration_since(requested).unwrap_or(Duration::ZERO)
625+
}
626+
574627
#[allow(clippy::too_many_arguments)]
575628
fn call_scheduled_reducer_with_tx(
576629
module_info: &ModuleInfo,
@@ -755,7 +808,9 @@ fn call_params_for_queued_item<T>(
755808
) -> anyhow::Result<(Timestamp, Instant, T)>,
756809
) -> anyhow::Result<Option<(Timestamp, Instant, T)>> {
757810
Ok(Some(match item {
758-
QueueItem::Id { id, function_name, at } => {
811+
QueueItem::Id {
812+
id, function_name, at, ..
813+
} => {
759814
let Some(schedule_row) = get_schedule_row_mut(tx, db, id)? else {
760815
// If the row is not found, it means the schedule is cancelled by the user.
761816
return Ok(None);

crates/core/src/worker_metrics/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,12 @@ metrics_group!(
534534
#[buckets(100e-6, 500e-6, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10)]
535535
pub reducer_wait_time: HistogramVec,
536536

537+
#[name = spacetime_scheduled_function_delay_seconds]
538+
#[help = "The amount of time (in seconds) between when a scheduled function was due and when the scheduler began invoking it"]
539+
#[labels(db: Identity, function: str)]
540+
#[buckets(0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30, 60, 300)]
541+
pub scheduled_function_delay: HistogramVec,
542+
537543
#[name = spacetime_worker_wasm_instance_errors_total]
538544
#[help = "The number of fatal WASM instance errors, such as reducer panics."]
539545
#[labels(database_identity: Identity, module_hash: Hash, reducer_symbol: str)]

0 commit comments

Comments
 (0)