Skip to content

Commit 87ffaa7

Browse files
committed
compute: stamp dataflow installation and hydration start
`mz_introspection.mz_compute_hydration_times_per_worker` reported only how long hydration took. That is enough to see a duration but not to place it in time, so a durable history cannot key an episode or tell a queued dataflow from a slow one. Add `installed_at`, `started_at`, and `hydrated_at` to the log, stamped by the replica. `installed_at` is taken when the export is created and is stable across an environmentd restart, which makes it usable as part of an episode's identity. `started_at` is only reported if the replica observed a start before reporting completion. An import-free dataflow is never suspended, so its `Schedule` can arrive after it has already hydrated, and stamping that late arrival would invent an interval nobody measured. NULL means the start was not observed. The columns are additive. The log keeps its name, OID, and object kind, so its generated per-replica index and the relations built on it are unchanged. Ref: CPU-210
1 parent 3405a66 commit 87ffaa7

7 files changed

Lines changed: 201 additions & 21 deletions

File tree

Cargo.lock

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

src/compute-client/src/logging.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,18 @@ impl LogVariant {
355355
.with_column("export_id", SqlScalarType::String.nullable(false))
356356
.with_column("worker_id", SqlScalarType::UInt64.nullable(false))
357357
.with_column("time_ns", SqlScalarType::UInt64.nullable(true))
358+
.with_column(
359+
"installed_at",
360+
SqlScalarType::TimestampTz { precision: None }.nullable(false),
361+
)
362+
.with_column(
363+
"started_at",
364+
SqlScalarType::TimestampTz { precision: None }.nullable(true),
365+
)
366+
.with_column(
367+
"hydrated_at",
368+
SqlScalarType::TimestampTz { precision: None }.nullable(true),
369+
)
358370
.with_key(vec![0, 1])
359371
.finish(),
360372

src/compute/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ workspace = true
1313
anyhow.workspace = true
1414
async-stream.workspace = true
1515
bytesize.workspace = true
16+
chrono.workspace = true
1617
columnar.workspace = true
1718
columnation.workspace = true
1819
dec = { workspace = true, features = ["serde"] }

src/compute/src/compute_state.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,20 @@ impl<'a> ActiveComputeState<'a> {
725725
// dataflow can export multiple collections and they all share one suspension token, so the
726726
// computation of a dataflow will only start once all its exported collections have been
727727
// scheduled.
728+
// NOTE: We stamp a start per export here, which only coincides with the
729+
// shared token's release because every compute dataflow has exactly one
730+
// export today (`sequential_hydration.rs` asserts it). If multi-export
731+
// dataflows land, this has to move to where the token is released and fan
732+
// out to every export, or an earlier-scheduled export reports a start
733+
// before any work could begin.
734+
if let Some(logging) = self
735+
.compute_state
736+
.collections
737+
.get(&id)
738+
.and_then(|collection| collection.logging.as_ref())
739+
{
740+
logging.set_started();
741+
}
728742
let suspension_token = self.compute_state.suspended_collections.remove(&id);
729743
drop(suspension_token);
730744
}
@@ -871,6 +885,7 @@ impl<'a> ActiveComputeState<'a> {
871885

872886
let logging =
873887
CollectionLogging::new(id, logger.clone(), *dataflow_index, std::iter::empty());
888+
logging.set_started();
874889
collection.logging = Some(logging);
875890

876891
let existing = self.compute_state.collections.insert(id, collection);

src/compute/src/logging/compute.rs

Lines changed: 166 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ use std::fmt::{Display, Write};
1515
use std::rc::Rc;
1616
use std::time::{Duration, Instant};
1717

18+
use chrono::{DateTime, Utc};
1819
use columnar::{Columnar, Index, Ref};
1920
use differential_dataflow::VecCollection;
2021
use differential_dataflow::collection::AsCollection;
2122
use differential_dataflow::trace::{BatchReader, Cursor, Navigable};
2223
use mz_compute_types::plan::LirId;
2324
use mz_ore::cast::CastFrom;
25+
use mz_repr::adt::timestamp::CheckedTimestamp;
2426
use mz_repr::{Datum, Diff, GlobalId, Row, RowRef, Timestamp};
2527
use mz_timely_util::columnar::batcher;
2628
use mz_timely_util::columnar::builder::ColumnBuilder;
@@ -163,6 +165,13 @@ pub struct Hydration {
163165
pub export_id: GlobalId,
164166
}
165167

168+
/// An export began hydrating.
169+
#[derive(Debug, Clone, PartialOrd, PartialEq, Columnar)]
170+
pub struct HydrationStart {
171+
/// Identifier of the export.
172+
pub export_id: GlobalId,
173+
}
174+
166175
/// An operator's hydration status changed.
167176
#[derive(Debug, Clone, PartialOrd, PartialEq, Columnar)]
168177
pub struct OperatorHydration {
@@ -225,6 +234,8 @@ pub enum ComputeEvent {
225234
DataflowShutdown(DataflowShutdown),
226235
/// The number of errors in a dataflow export has changed.
227236
ErrorCount(ErrorCount),
237+
/// A dataflow export began hydrating.
238+
HydrationStart(HydrationStart),
228239
/// A dataflow export was hydrated.
229240
Hydration(Hydration),
230241
/// A dataflow operator's hydration status changed.
@@ -620,11 +631,17 @@ impl DemuxState {
620631
&mut self,
621632
export_id: GlobalId,
622633
time_ns: Option<u64>,
634+
installed_at: CheckedTimestamp<DateTime<Utc>>,
635+
started_at: Option<CheckedTimestamp<DateTime<Utc>>>,
636+
hydrated_at: Option<CheckedTimestamp<DateTime<Utc>>>,
623637
) -> (&RowRef, &RowRef) {
624638
self.hydration_time_packer.pack_slice(&[
625639
make_string_datum(export_id, &mut self.scratch_string_a),
626640
Datum::UInt64(u64::cast_from(self.worker_id)),
627641
Datum::from(time_ns),
642+
Datum::TimestampTz(installed_at),
643+
started_at.map_or(Datum::Null, Datum::TimestampTz),
644+
hydrated_at.map_or(Datum::Null, Datum::TimestampTz),
628645
])
629646
}
630647

@@ -732,18 +749,27 @@ struct ExportState {
732749
error_count: Diff,
733750
/// When this export was created.
734751
created_at: Instant,
752+
/// Wall-clock time at which this export was installed.
753+
installed_at: CheckedTimestamp<DateTime<Utc>>,
754+
/// Wall-clock time at which this export began hydrating.
755+
started_at: Option<CheckedTimestamp<DateTime<Utc>>>,
756+
/// Wall-clock time at which this export finished hydrating.
757+
hydrated_at: Option<CheckedTimestamp<DateTime<Utc>>>,
735758
/// Whether the exported collection is hydrated.
736759
hydration_time_ns: Option<u64>,
737760
/// Hydration status of operators feeding this export.
738761
operator_hydration: BTreeMap<LirId, bool>,
739762
}
740763

741764
impl ExportState {
742-
fn new(dataflow_index: usize) -> Self {
765+
fn new(dataflow_index: usize, installed_at: CheckedTimestamp<DateTime<Utc>>) -> Self {
743766
Self {
744767
dataflow_index,
745768
error_count: Diff::ZERO,
746769
created_at: Instant::now(),
770+
installed_at,
771+
started_at: None,
772+
hydrated_at: None,
747773
hydration_time_ns: None,
748774
operator_hydration: BTreeMap::new(),
749775
}
@@ -799,6 +825,21 @@ impl DemuxHandler<'_, '_, '_> {
799825
rounded.try_into().expect("must fit")
800826
}
801827

828+
/// Returns the event's wall-clock time, unrounded.
829+
///
830+
/// Unlike `ts`, which rounds up to the logging interval to publish an update,
831+
/// this is the time the event actually happened. Reading `self.time` as a
832+
/// Unix duration is only valid because the logging clock is anchored at the
833+
/// epoch. A boot-relative anchor would silently yield 1970 timestamps in a
834+
/// durable table.
835+
fn event_timestamp(&self) -> CheckedTimestamp<DateTime<Utc>> {
836+
let seconds = i64::try_from(self.time.as_secs()).expect("event timestamp must fit");
837+
DateTime::from_timestamp(seconds, self.time.subsec_nanos())
838+
.expect("event timestamp must be valid")
839+
.try_into()
840+
.expect("event timestamp must fit")
841+
}
842+
802843
/// Handle the given compute event.
803844
fn handle(&mut self, event: Ref<'_, ComputeEvent>) {
804845
use ComputeEventReference::*;
@@ -818,6 +859,7 @@ impl DemuxHandler<'_, '_, '_> {
818859
}
819860
DataflowShutdown(shutdown) => self.handle_dataflow_shutdown(shutdown),
820861
ErrorCount(error_count) => self.handle_error_count(error_count),
862+
HydrationStart(hydration) => self.handle_hydration_start(hydration),
821863
Hydration(hydration) => self.handle_hydration(hydration),
822864
OperatorHydration(hydration) => self.handle_operator_hydration(hydration),
823865
LirMapping(mapping) => self.handle_lir_mapping(mapping),
@@ -834,19 +876,36 @@ impl DemuxHandler<'_, '_, '_> {
834876
) {
835877
let export_id = Columnar::into_owned(export_id);
836878
let ts = self.ts();
879+
let installed_at = self.event_timestamp();
837880
let datum = self.state.pack_export_update(export_id, dataflow_index);
838881
self.output.export.give((datum, ts, Diff::ONE));
839882

840883
let existing = self
841884
.state
842885
.exports
843-
.insert(export_id, ExportState::new(dataflow_index));
844-
if existing.is_some() {
886+
.insert(export_id, ExportState::new(dataflow_index, installed_at));
887+
if let Some(existing) = existing {
845888
error!(%export_id, "export already registered");
889+
// The stale row's value carries its own timestamps, so it is not
890+
// identical to the one we are about to insert. Retract it here or it
891+
// stays in the collection forever: the drop handler only ever sees
892+
// the state we just overwrote it with.
893+
let datum = self.state.pack_hydration_time_update(
894+
export_id,
895+
existing.hydration_time_ns,
896+
existing.installed_at,
897+
existing.started_at,
898+
existing.hydrated_at,
899+
);
900+
self.output
901+
.hydration_time
902+
.give((datum, ts, Diff::MINUS_ONE));
846903
}
847904

848905
// Insert hydration time logging for this export.
849-
let datum = self.state.pack_hydration_time_update(export_id, None);
906+
let datum =
907+
self.state
908+
.pack_hydration_time_update(export_id, None, installed_at, None, None);
850909
self.output.hydration_time.give((datum, ts, Diff::ONE));
851910
}
852911

@@ -875,9 +934,13 @@ impl DemuxHandler<'_, '_, '_> {
875934
}
876935

877936
// Remove hydration time logging for this export.
878-
let datum = self
879-
.state
880-
.pack_hydration_time_update(export_id, export.hydration_time_ns);
937+
let datum = self.state.pack_hydration_time_update(
938+
export_id,
939+
export.hydration_time_ns,
940+
export.installed_at,
941+
export.started_at,
942+
export.hydrated_at,
943+
);
881944
self.output
882945
.hydration_time
883946
.give((datum, ts, Diff::MINUS_ONE));
@@ -961,31 +1024,105 @@ impl DemuxHandler<'_, '_, '_> {
9611024
}
9621025
}
9631026

964-
fn handle_hydration(&mut self, HydrationReference { export_id }: Ref<'_, Hydration>) {
1027+
fn handle_hydration_start(
1028+
&mut self,
1029+
HydrationStartReference { export_id }: Ref<'_, HydrationStart>,
1030+
) {
9651031
let ts = self.ts();
1032+
let started_at = self.event_timestamp();
9661033
let export_id = Columnar::into_owned(export_id);
9671034

968-
let Some(export) = self.state.exports.get_mut(&export_id) else {
969-
error!(%export_id, "hydration event for unknown export");
970-
return;
1035+
let (old, new) = {
1036+
let Some(export) = self.state.exports.get_mut(&export_id) else {
1037+
error!(%export_id, "hydration start event for unknown export");
1038+
return;
1039+
};
1040+
// A `Schedule` is delivered even for a collection that already
1041+
// hydrated, because `StartSignal` gates only imported inputs and an
1042+
// import-free dataflow is never suspended. Recording that late
1043+
// arrival as the start of hydration would invent an interval that
1044+
// was never observed, so leave `started_at` NULL instead. Repeat
1045+
// events after reconciliation are ignored for the same reason.
1046+
if export.started_at.is_some() || export.hydrated_at.is_some() {
1047+
return;
1048+
}
1049+
1050+
let old = (
1051+
export.hydration_time_ns,
1052+
export.installed_at,
1053+
export.started_at,
1054+
export.hydrated_at,
1055+
);
1056+
export.started_at = Some(started_at);
1057+
let new = (
1058+
export.hydration_time_ns,
1059+
export.installed_at,
1060+
export.started_at,
1061+
export.hydrated_at,
1062+
);
1063+
(old, new)
9711064
};
972-
if export.hydration_time_ns.is_some() {
973-
// Hydration events for already hydrated dataflows can occur when a dataflow is reused
974-
// after reconciliation. We can simply ignore these.
975-
return;
976-
}
9771065

978-
let duration = export.created_at.elapsed();
979-
let nanos = u64::try_from(duration.as_nanos()).expect("must fit");
980-
export.hydration_time_ns = Some(nanos);
1066+
let retraction = self
1067+
.state
1068+
.pack_hydration_time_update(export_id, old.0, old.1, old.2, old.3);
1069+
self.output
1070+
.hydration_time
1071+
.give((retraction, ts, Diff::MINUS_ONE));
1072+
let insertion = self
1073+
.state
1074+
.pack_hydration_time_update(export_id, new.0, new.1, new.2, new.3);
1075+
self.output.hydration_time.give((insertion, ts, Diff::ONE));
1076+
}
1077+
1078+
fn handle_hydration(&mut self, HydrationReference { export_id }: Ref<'_, Hydration>) {
1079+
let ts = self.ts();
1080+
let hydrated_at = self.event_timestamp();
1081+
let export_id = Columnar::into_owned(export_id);
1082+
1083+
let (old, new) = {
1084+
let Some(export) = self.state.exports.get_mut(&export_id) else {
1085+
error!(%export_id, "hydration event for unknown export");
1086+
return;
1087+
};
1088+
if export.hydration_time_ns.is_some() {
1089+
// Hydration events for already hydrated dataflows can occur when a dataflow is
1090+
// reused after reconciliation. We can simply ignore these.
1091+
return;
1092+
}
1093+
1094+
let old = (
1095+
export.hydration_time_ns,
1096+
export.installed_at,
1097+
export.started_at,
1098+
export.hydrated_at,
1099+
);
1100+
let duration = export.created_at.elapsed();
1101+
export.hydration_time_ns = Some(u64::try_from(duration.as_nanos()).expect("must fit"));
1102+
// `started_at` stays as it is, including NULL. An import-free
1103+
// dataflow can hydrate without ever being suspended, and we do not
1104+
// invent a start we never observed. Consumers read a NULL start as
1105+
// "not observed", which keeps `installed_at <= started_at <=
1106+
// hydrated_at` true for every non-NULL pair.
1107+
export.hydrated_at = Some(hydrated_at);
1108+
let new = (
1109+
export.hydration_time_ns,
1110+
export.installed_at,
1111+
export.started_at,
1112+
export.hydrated_at,
1113+
);
1114+
(old, new)
1115+
};
9811116

982-
let retraction = self.state.pack_hydration_time_update(export_id, None);
1117+
let retraction = self
1118+
.state
1119+
.pack_hydration_time_update(export_id, old.0, old.1, old.2, old.3);
9831120
self.output
9841121
.hydration_time
9851122
.give((retraction, ts, Diff::MINUS_ONE));
9861123
let insertion = self
9871124
.state
988-
.pack_hydration_time_update(export_id, Some(nanos));
1125+
.pack_hydration_time_update(export_id, new.0, new.1, new.2, new.3);
9891126
self.output.hydration_time.give((insertion, ts, Diff::ONE));
9901127
}
9911128

@@ -1394,6 +1531,14 @@ impl CollectionLogging {
13941531
}
13951532
}
13961533

1534+
/// Records that the collection began hydrating.
1535+
pub fn set_started(&self) {
1536+
self.logger
1537+
.log(&ComputeEvent::HydrationStart(HydrationStart {
1538+
export_id: self.export_id,
1539+
}));
1540+
}
1541+
13971542
/// Set the collection as hydrated.
13981543
pub fn set_hydrated(&self) {
13991544
self.logger.log(&ComputeEvent::Hydration(Hydration {

test/sqllogictest/mz_catalog_server_index_accounting.slt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,9 @@ mz_compute_hydration_times object_id
345345
mz_compute_hydration_times replica_id
346346
mz_compute_hydration_times time_ns
347347
mz_compute_hydration_times_per_worker export_id
348+
mz_compute_hydration_times_per_worker hydrated_at
349+
mz_compute_hydration_times_per_worker installed_at
350+
mz_compute_hydration_times_per_worker started_at
348351
mz_compute_hydration_times_per_worker time_ns
349352
mz_compute_hydration_times_per_worker worker_id
350353
mz_compute_import_frontiers_per_worker export_id

0 commit comments

Comments
 (0)