mz_storage/source/sql_server/progress.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! A "non-critical" operator that tracks the progress of a [`SqlServerSourceConnection`].
11//!
12//! The operator does the following:
13//!
14//! * At some cadence `timestamp_interval` will probe the source for the max
15//! [`Lsn`], emit the upstream known offset, and update `SourceStatistics`.
16//! * Listen to a provided [`futures::Stream`] of resume uppers, which represents
17//! the durably committed upper for _all_ of the subsources/exports associated
18//! with this source. As the source makes progress this operator does two
19//! things:
20//! 1. If [`CDC_CLEANUP_CHANGE_TABLE`] is enabled, will delete entries from
21//! the upstream change table that we've already ingested.
22//! 2. Update `SourceStatistics` to notify listeners of a new
23//! "committed LSN".
24//!
25//! [`SqlServerSourceConnection`]: mz_storage_types::sources::SqlServerSourceConnection
26
27use std::collections::{BTreeMap, BTreeSet};
28
29use futures::StreamExt;
30use mz_ore::future::InTask;
31use mz_repr::GlobalId;
32use mz_sql_server_util::cdc::Lsn;
33use mz_sql_server_util::inspect::{get_latest_restore_history_id, get_max_lsn};
34use mz_storage_types::connections::SqlServerConnectionDetails;
35use mz_storage_types::dyncfgs::SQL_SERVER_SOURCE_VALIDATE_RESTORE_HISTORY;
36use mz_storage_types::sources::SqlServerSourceExtras;
37use mz_storage_types::sources::sql_server::{
38 CDC_CLEANUP_CHANGE_TABLE, CDC_CLEANUP_CHANGE_TABLE_MAX_DELETES,
39};
40use mz_timely_util::builder_async::{OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton};
41use timely::container::CapacityContainerBuilder;
42use timely::dataflow::operators::vec::Map;
43use timely::dataflow::{Scope, StreamVec};
44use timely::progress::Antichain;
45
46use crate::source::sql_server::{ReplicationError, SourceOutputInfo, TransientError};
47use crate::source::types::Probe;
48use crate::source::{RawSourceCreationConfig, probe};
49
50/// Used as a partition ID to determine the worker that is responsible for
51/// handling progress.
52static PROGRESS_WORKER: &str = "progress";
53
54pub(crate) fn render<'scope>(
55 scope: Scope<'scope, Lsn>,
56 config: RawSourceCreationConfig,
57 connection: SqlServerConnectionDetails,
58 outputs: BTreeMap<GlobalId, SourceOutputInfo>,
59 committed_uppers: impl futures::Stream<Item = Antichain<Lsn>> + 'static,
60 extras: SqlServerSourceExtras,
61) -> (
62 StreamVec<'scope, Lsn, ReplicationError>,
63 StreamVec<'scope, Lsn, Probe<Lsn>>,
64 PressOnDropButton,
65) {
66 let op_name = format!("SqlServerProgress({})", config.id);
67 let mut builder = AsyncOperatorBuilder::new(op_name, scope);
68
69 let (probe_output, probe_stream) = builder.new_output::<CapacityContainerBuilder<_>>();
70
71 let (button, transient_errors) = builder.build_fallible::<TransientError, _>(move |caps| {
72 Box::pin(async move {
73 let [probe_cap]: &mut [_; 1] = caps.try_into().unwrap();
74
75 let emit_probe = |cap, probe: Probe<Lsn>| {
76 probe_output.give(cap, probe);
77 };
78
79 // Only a single worker is responsible for processing progress.
80 if !config.responsible_for(PROGRESS_WORKER) {
81 // Emit 0 to mark this worker as having started up correctly.
82 for stat in config.statistics.values() {
83 stat.set_offset_known(0);
84 stat.set_offset_committed(0);
85 }
86 return Ok(());
87 }
88
89 // Retrieve the latest upstream LSN eagerly to ensure the lag calculation
90 // (offset_known - offset_committed) is non-negative. Statistics represents these as
91 // uint8, which would cause the calculation to underflow for the brief period between
92 // setting offset_committed here and offset_known further below.
93 let conn_config = connection
94 .resolve_config(
95 &config.config.connection_context.secrets_reader,
96 &config.config,
97 InTask::Yes,
98 )
99 .await?;
100 let mut client = mz_sql_server_util::Client::connect(conn_config).await?;
101 // increment here to match known_offset calculation below
102 let next_upstream_lsn: Lsn = get_max_lsn(&mut client).await?.increment();
103
104 // Seed `offset_committed` from the resumption LSN, or if not set, from the
105 // upstream's current max LSN. Otherwise, it stays at the default 0 until the
106 // initial snapshot durably commits and the first resume upper arrives, which
107 // for a large snapshot can be a long time. During that window the ingestion-lag
108 // calculation subtracts 0 from the (large) upstream LSN and reports an
109 // enormous, bogus lag.
110 //
111 // This defaults to upstream's current max offset instead of `initial_lsn` because
112 // `initial_lsn` can be ahead of the value returned by `sys.fn_cdc_get_max_lsn`. This
113 // is a very obscure edge case where a user has a CDC enabled table, creates a new one
114 // and configures a source for at least the second table immediately after, without
115 // performing any DML operations.
116 let mut max_committed_lsn = outputs
117 .values()
118 // resume_lsn_or will panic if info resume_upper is empty
119 .map(|info| info.resume_lsn_or(next_upstream_lsn))
120 .min()
121 .unwrap_or(next_upstream_lsn);
122
123 for stat in config.statistics.values() {
124 stat.set_offset_known(next_upstream_lsn.abbreviate());
125 stat.set_offset_committed(max_committed_lsn.abbreviate());
126 }
127
128
129 // Terminate the progress probes if a restore has happened. Replication operator will
130 // emit a definite error at the max LSN, but we also have to terminate the RLU probes
131 // to ensure that the error propogates to downstream consumers, otherwise it will
132 // wait in reclock as the server LSN will always be less than the LSN of the definite
133 // error.
134 let current_restore_history_id = get_latest_restore_history_id(&mut client).await?;
135 if current_restore_history_id != extras.restore_history_id
136 && SQL_SERVER_SOURCE_VALIDATE_RESTORE_HISTORY.get(config.config.config_set()) {
137 tracing::warn!("Restore detected, exiting");
138 return Ok(());
139 }
140
141
142 let timestamp_interval = config.timestamp_interval;
143 let mut probe_ticker = probe::Ticker::new(move || timestamp_interval, config.now_fn);
144
145 // Offset that is measured from the upstream SQL Server instance. Tracked to detect an offset that moves backwards.
146 let mut prev_offset_known: Option<Lsn> = None;
147
148 // This stream of "resume uppers" tracks all of the Lsn's that we have durably
149 // committed for all subsources/exports and thus we can notify the upstream that the
150 // change tables can be cleaned up.
151 let mut committed_uppers = std::pin::pin!(committed_uppers);
152 let cleanup_change_table =
153 CDC_CLEANUP_CHANGE_TABLE.handle(config.config.config_set());
154 let cleanup_max_deletes =
155 CDC_CLEANUP_CHANGE_TABLE_MAX_DELETES
156 .handle(config.config.config_set());
157 let capture_instances: BTreeSet<_> = outputs
158 .into_values()
159 .map(|info| info.capture_instance)
160 .collect();
161
162 loop {
163 tokio::select! {
164 probe_ts = probe_ticker.tick() => {
165 let max_lsn: Lsn = get_max_lsn(&mut client).await?;
166 // We have to return max_lsn + 1 in the probe so that the downstream consumers of
167 // the probe view the actual max lsn as fully committed and all data at that LSN
168 // as no longer subject to change. If we don't increment the LSN before emitting
169 // the probe then data will not be queryable in the tables produced by the Source.
170 let known_lsn = max_lsn.increment();
171 for stat in config.statistics.values() {
172 stat.set_offset_known(known_lsn.abbreviate());
173 }
174
175
176 // The DB should never go backwards, but it's good to know if it does.
177 let prev_known_lsn = match prev_offset_known {
178 None => {
179 prev_offset_known = Some(known_lsn);
180 known_lsn
181 },
182 Some(prev) => prev,
183 };
184 if known_lsn < prev_known_lsn {
185 mz_ore::soft_panic_or_log!(
186 "upstream SQL Server went backwards \
187 in time, current LSN: {known_lsn}, \
188 last known {prev_known_lsn}",
189 );
190 continue;
191 }
192 let probe = Probe {
193 probe_ts,
194 upstream_frontier: Antichain::from_elem(known_lsn),
195 };
196 emit_probe(&probe_cap[0], probe);
197 prev_offset_known = Some(known_lsn);
198 },
199 Some(committed_upper) = committed_uppers.next() => {
200 let Some(committed_upper) = committed_upper.as_option() else {
201 // It's possible that the source has been dropped, in which case this can
202 // observe an empty upper. This operator should continue to loop until
203 // the drop dataflow propagates.
204 continue;
205 };
206
207 // If enabled, tell the upstream SQL Server instance to
208 // cleanup the underlying change table.
209 if cleanup_change_table.get() {
210 for instance in &capture_instances {
211 // TODO(sql_server3): The number of rows that got cleaned
212 // up should be present in informational notices sent back
213 // from the upstream, but the tiberius crate does not
214 // expose these.
215 let cleanup_result =
216 mz_sql_server_util::inspect::cleanup_change_table(
217 &mut client,
218 instance,
219 committed_upper,
220 cleanup_max_deletes.get(),
221 ).await;
222 // TODO(sql_server2): Track this in a more user observable way.
223 if let Err(err) = cleanup_result {
224 tracing::warn!(?err, %instance, "cleanup of change table failed!");
225 }
226 }
227 }
228 // Never regress below the seeded resumption LSN. During the initial
229 // snapshot the resume upper sits at the minimum, which would otherwise
230 // drag the committed offset back to 0 and reintroduce the bogus lag.
231 if *committed_upper > max_committed_lsn {
232 max_committed_lsn = *committed_upper;
233 }
234 for stat in config.statistics.values() {
235 stat.set_offset_committed(max_committed_lsn.abbreviate());
236 }
237 }
238 };
239 }
240 })
241 });
242
243 let error_stream = transient_errors.map(ReplicationError::Transient);
244
245 (error_stream, probe_stream, button.press_on_drop())
246}