Skip to content

Commit 1e8bb94

Browse files
committed
compute: derive the time dependence from the read set, and say so in production
`soft_assert_no_log!` is silent when soft assertions are off, so with the import list as the only input to a dataflow's wall-clock dependence, production rested entirely on the prune covering every path that installs a dataflow. A path slipping past would pin the output frontier at the replica expiration, leave the collection never reporting final, and say nothing anywhere, which is the silence SQL-635 presented as. `create_dataflow` now computes `used_import_ids` once and uses it twice. `determine_time_dependence` takes it and counts through it rather than over the raw import list, so the consumer whose wrong answer hangs an environment is correct whether or not the prune ran. The tightness check reports against the same set, and because the walk is paid for regardless it can afford `soft_assert_or_log!`, which reports a loose list in production instead of nothing. The prune keeps its job. Read holds and persist sources are read off the import list directly, so it is what reclaims them. An export-less description gets its own check. It has no answer to "what do the exports read", so the import check would have failed it for the wrong reason, and the prune already leaves such a description alone. Stating the two invariants separately keeps both messages accurate.
1 parent 27f8929 commit 1e8bb94

2 files changed

Lines changed: 66 additions & 31 deletions

File tree

src/compute-client/src/controller.rs

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ use mz_expr::row::RowCollection;
5252
use mz_ore::cast::CastFrom;
5353
use mz_ore::metrics::MetricsRegistry;
5454
use mz_ore::now::NowFn;
55-
use mz_ore::soft_assert_no_log;
55+
use mz_ore::soft_assert_or_log;
5656
use mz_ore::tracing::OpenTelemetryContext;
5757
use mz_persist_types::PersistLocation;
5858
use mz_repr::{GlobalId, RelationDesc, Row, Timestamp};
@@ -813,25 +813,43 @@ impl ComputeController {
813813
return Err(EmptyAsOfForCopyTo);
814814
}
815815

816-
// Validation: every import is read
816+
// Validation: the dataflow exports something
817817
//
818-
// The import list is meant to be exactly the imports the exports read. `optimize_dataflow`
819-
// prunes it to that, and the read holds, the time dependence, and the persist sources the
820-
// replicas build are all derived from it below, so each of them describes a dataflow other
821-
// than the one that will run once the list is loose. This catches a producer that stops
822-
// pruning.
818+
// An export-less description has nothing to render and no answer to "what do the exports
819+
// read", which the checks below are phrased in terms of. `optimize_dataflow` leaves such a
820+
// description's imports alone for that reason, so one arriving here would fail the import
821+
// check for the wrong reason.
822+
soft_assert_or_log!(
823+
!dataflow.index_exports.is_empty() || !dataflow.sink_exports.is_empty(),
824+
"dataflow {} has no exports",
825+
dataflow.debug_name,
826+
);
827+
828+
// The imports the exports actually read. `optimize_dataflow` prunes the import list to
829+
// exactly this set, so the two agree unless a producer stopped pruning.
823830
//
824-
// `soft_assert_no_log!` rather than a logging variant because the check walks the plan and
825-
// this runs per peek, and the walk is worth paying for only where it can fail a test.
826-
soft_assert_no_log!(
827-
{
828-
let used = dataflow.used_import_ids();
829-
dataflow.import_ids().all(|id| used.contains(&id))
830-
},
831+
// Computed once and used twice: the check below reports a loose list, and
832+
// `determine_time_dependence` counts through it rather than over the raw list. That
833+
// consumer is the one whose wrong answer hangs an environment: an import no export reads
834+
// would report wall-clock dependence for a dataflow whose exports are constant, earning it
835+
// a dataflow expiration that pins the output frontier days short of the empty antichain,
836+
// and nothing downstream would learn the collection is final. Deriving it from this set
837+
// makes that correct by construction, leaving the prune to reclaim the read hold and the
838+
// persist source.
839+
let used_imports = dataflow.used_import_ids();
840+
841+
// Validation: every import is read
842+
//
843+
// The read holds and the persist sources the replicas build are still derived from the raw
844+
// list below, so a loose one describes a dataflow other than the one that will run. A
845+
// logging variant rather than `soft_assert_no_log!`: the walk is paid for above either way,
846+
// so reporting it in production costs only the comparison.
847+
soft_assert_or_log!(
848+
dataflow.import_ids().all(|id| used_imports.contains(&id)),
831849
"dataflow {} imports collections no export reads: imports {:?}, read {:?}",
832850
dataflow.debug_name,
833851
dataflow.import_ids().collect::<Vec<_>>(),
834-
dataflow.used_import_ids(),
852+
used_imports,
835853
);
836854

837855
// Validation: input collections
@@ -854,7 +872,7 @@ impl ComputeController {
854872
}
855873
}
856874
let time_dependence = self
857-
.determine_time_dependence(instance_id, &dataflow)
875+
.determine_time_dependence(instance_id, &dataflow, &used_imports)
858876
.expect("must exist");
859877

860878
let instance = self.instance_mut(instance_id).expect("validated");
@@ -1036,31 +1054,43 @@ impl ComputeController {
10361054
}
10371055

10381056
/// Determine the time dependence for a dataflow.
1057+
///
1058+
/// `used_imports` are the imports the exports read, as
1059+
/// [`DataflowDescription::used_import_ids`] reports them. Only those count: an import no export
1060+
/// reads would report wall-clock dependence for a dataflow whose exports are constant, and that
1061+
/// earns it a dataflow expiration, which pins its output frontier at the expiration time. A
1062+
/// constant export's frontier is the empty antichain, so the pin would hold it days short of
1063+
/// the truth and whoever reads that frontier would never learn the collection can no longer
1064+
/// change.
1065+
///
1066+
/// `optimize_dataflow` prunes the import list to this set, so the two agree and the filtering
1067+
/// is a no-op. It is here because this is the consumer whose wrong answer hangs an environment,
1068+
/// and deriving the answer from the read set makes it independent of the list staying tight.
10391069
fn determine_time_dependence(
10401070
&self,
10411071
instance_id: ComputeInstanceId,
10421072
dataflow: &DataflowDescription<mz_compute_types::plan::LirRelationExpr, ()>,
1073+
used_imports: &BTreeSet<GlobalId>,
10431074
) -> Result<Option<TimeDependence>, TimeDependenceError> {
10441075
let instance = self
10451076
.instance(instance_id)
10461077
.map_err(|err| TimeDependenceError::InstanceMissing(err.0))?;
10471078
let mut time_dependencies = Vec::new();
10481079

1049-
// Every import counts, which is only the right answer because the import list is the set of
1050-
// imports the exports read. An import no export reads would report wall-clock dependence
1051-
// for a dataflow whose exports are constant, and that earns it a dataflow expiration, which
1052-
// pins its output frontier at the expiration time. A constant export's frontier is the
1053-
// empty antichain, so the pin would hold it days short of the truth and whoever reads that
1054-
// frontier would never learn the collection can no longer change. `create_dataflow` asserts
1055-
// the list is tight before we get here.
1056-
for id in dataflow.imported_index_ids() {
1080+
for id in dataflow
1081+
.imported_index_ids()
1082+
.filter(|id| used_imports.contains(id))
1083+
{
10571084
let dependence = instance
10581085
.get_time_dependence(id)
10591086
.map_err(|err| TimeDependenceError::CollectionMissing(err.0))?;
10601087
time_dependencies.push(dependence);
10611088
}
10621089

1063-
'source: for id in dataflow.imported_source_ids() {
1090+
'source: for id in dataflow
1091+
.imported_source_ids()
1092+
.filter(|id| used_imports.contains(id))
1093+
{
10641094
// We first check whether the id is backed by a compute object, in which case we use
10651095
// the time dependence we know. This is true for storage sinks.
10661096
for instance in self.instances.values() {

src/transform/src/dataflow.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -522,12 +522,17 @@ pub fn optimize_dataflow_snapshot(dataflow: &mut DataflowDesc) -> Result<(), Tra
522522
/// can drop the last `Get` of one, for instance by folding a selection to a constant.
523523
///
524524
/// An import that survives that is not free. Every worker builds a `persist_source` for it and
525-
/// decodes a shard into a stream nobody consumes, the controller takes a read hold that pins the
526-
/// collection's `since` for as long as the dataflow lives, and the dataflow reports a wall-clock
527-
/// dependence none of its exports have. The last of those is the dangerous one. It earns a dataflow
528-
/// whose exports can never change again an expiration, and that pins their output frontier at the
529-
/// expiration time rather than letting it reach the empty antichain, so nothing downstream learns
530-
/// the collection is final.
525+
/// decodes a shard into a stream nobody consumes, and the controller takes a read hold that pins
526+
/// the collection's `since` for as long as the dataflow lives. Both are read off the import list
527+
/// directly, so pruning is what reclaims them.
528+
///
529+
/// A third consumer, the wall-clock dependence a dataflow reports, is the one whose wrong answer
530+
/// does real damage: it earns a dataflow whose exports can never change again an expiration, which
531+
/// pins their output frontier at the expiration time rather than letting it reach the empty
532+
/// antichain, so nothing downstream learns the collection is final.
533+
/// `ComputeController::determine_time_dependence` derives that from the read set rather than from
534+
/// the import list, so it does not depend on this pass having run. `create_dataflow` also reports a
535+
/// list this pass left loose.
531536
///
532537
/// The input plans should be normalized with `NormalizeLets`, for the same reason
533538
/// [`prune_and_annotate_dataflow_index_imports`] wants them to be: an unused `Let` binding can

0 commit comments

Comments
 (0)