Skip to main content

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 uppers of the subsources/exports associated with
18//!   this source. As the source makes progress this operator does two things:
19//!     1. If [`CDC_CLEANUP_CHANGE_TABLE`] is enabled, will delete entries from
20//!        each capture instance's change table that _all_ of its exports have
21//!        ingested.
22//!     2. Update each export's `SourceStatistics` to notify listeners of its new
23//!        "committed LSN".
24//!
25//! [`SqlServerSourceConnection`]: mz_storage_types::sources::SqlServerSourceConnection
26
27use std::collections::BTreeMap;
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, ResumeUppers};
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 = ResumeUppers<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 seeded_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(seeded_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 the Lsn each subsource/export has durably
149            // committed, and thus we can notify the upstream that the change tables can be
150            // 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            // Each capture instance's exports, and the low water mark its change table was last
158            // cleaned up to. Replication resumes each capture instance from the least resume
159            // upper of its exports (`resume_lsns` in `replication.rs`), so its change table can
160            // be cleaned up to the meet of those exports alone.
161            let mut capture_instances: BTreeMap<_, (Vec<GlobalId>, Option<Lsn>)> =
162                BTreeMap::new();
163            for (id, info) in outputs {
164                capture_instances.entry(info.capture_instance).or_default().0.push(id);
165            }
166
167            loop {
168                tokio::select! {
169                    probe_ts = probe_ticker.tick() => {
170                        let max_lsn: Lsn = get_max_lsn(&mut client).await?;
171                        // We have to return max_lsn + 1 in the probe so that the downstream consumers of
172                        // the probe view the actual max lsn as fully committed and all data at that LSN
173                        // as no longer subject to change. If we don't increment the LSN before emitting
174                        // the probe then data will not be queryable in the tables produced by the Source.
175                        let known_lsn = max_lsn.increment();
176                        for stat in config.statistics.values() {
177                            stat.set_offset_known(known_lsn.abbreviate());
178                        }
179
180
181                        // The DB should never go backwards, but it's good to know if it does.
182                        let prev_known_lsn = match prev_offset_known {
183                            None => {
184                                prev_offset_known = Some(known_lsn);
185                                known_lsn
186                            },
187                            Some(prev) => prev,
188                        };
189                        if known_lsn < prev_known_lsn {
190                            mz_ore::soft_panic_or_log!(
191                                "upstream SQL Server went backwards \
192                                 in time, current LSN: {known_lsn}, \
193                                 last known {prev_known_lsn}",
194                            );
195                            continue;
196                        }
197                        let probe = Probe {
198                            probe_ts,
199                            upstream_frontier: Antichain::from_elem(known_lsn),
200                        };
201                        emit_probe(&probe_cap[0], probe);
202                        prev_offset_known = Some(known_lsn);
203                    },
204                    Some(uppers) = committed_uppers.next() => {
205                        // Never regress below the seeded resumption LSN. During the initial
206                        // snapshot an export's resume upper sits at the minimum, which would
207                        // otherwise drag its committed offset back to 0 and reintroduce the bogus
208                        // lag.
209                        for (id, upper) in &uppers.exports {
210                            let stat = config.statistics.get(id);
211                            if let (Some(lsn), Some(stat)) = (upper.as_option(), stat) {
212                                let lsn = std::cmp::max(*lsn, seeded_lsn);
213                                stat.set_offset_committed(lsn.abbreviate());
214                            }
215                        }
216
217                        // If enabled, tell the upstream SQL Server instance to
218                        // cleanup the underlying change table.
219                        if cleanup_change_table.get() {
220                            for (instance, (exports, cleaned)) in capture_instances.iter_mut() {
221                                let Some(low_water_mark) = committed_lsn(exports, &uppers.exports)
222                                else {
223                                    continue;
224                                };
225                                // `uppers` changes whenever any export's upper moves, usually
226                                // leaving this capture instance's low water mark unchanged. A
227                                // failed cleanup is retried once the low water mark moves.
228                                if *cleaned == Some(low_water_mark) {
229                                    continue;
230                                }
231                                *cleaned = Some(low_water_mark);
232                                // TODO(sql_server3): The number of rows that got cleaned
233                                // up should be present in informational notices sent back
234                                // from the upstream, but the tiberius crate does not
235                                // expose these.
236                                let cleanup_result =
237                                    mz_sql_server_util::inspect::cleanup_change_table(
238                                        &mut client,
239                                        instance,
240                                        &low_water_mark,
241                                        cleanup_max_deletes.get(),
242                                    ).await;
243                                // TODO(sql_server2): Track this in a more user observable way.
244                                if let Err(err) = cleanup_result {
245                                    tracing::warn!(?err, %instance, "cleanup of change table failed!");
246                                }
247                            }
248                        }
249                    }
250                };
251            }
252        })
253    });
254
255    let error_stream = transient_errors.map(ReplicationError::Transient);
256
257    (error_stream, probe_stream, button.press_on_drop())
258}
259
260/// The [`Lsn`] that every export in `exports` has durably committed through.
261///
262/// `None` while some export has not committed beyond the ingestion's as_of, and once every
263/// export's upper is empty, which happens when the source is dropped.
264fn committed_lsn(exports: &[GlobalId], uppers: &BTreeMap<GlobalId, Antichain<Lsn>>) -> Option<Lsn> {
265    let mut meet = None;
266    for id in exports {
267        if let Some(lsn) = uppers.get(id)?.as_option() {
268            meet = Some(meet.map_or(*lsn, |meet: Lsn| meet.min(*lsn)));
269        }
270    }
271    meet
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn lsn(block_id: u32) -> Lsn {
279        Lsn {
280            vlf_id: 1,
281            block_id,
282            record_id: 0,
283        }
284    }
285
286    #[mz_ore::test]
287    fn committed_lsn_is_meet_of_exports() {
288        let (a, b, closed) = (GlobalId::User(1), GlobalId::User(2), GlobalId::User(3));
289        let uppers = BTreeMap::from([
290            (a, Antichain::from_elem(lsn(5))),
291            (b, Antichain::from_elem(lsn(3))),
292            (closed, Antichain::new()),
293        ]);
294        assert_eq!(committed_lsn(&[a, b], &uppers), Some(lsn(3)));
295        assert_eq!(
296            committed_lsn(&[a, closed], &uppers),
297            Some(lsn(5)),
298            "an empty upper does not constrain the meet",
299        );
300        assert_eq!(
301            committed_lsn(&[closed], &uppers),
302            None,
303            "every upper is empty"
304        );
305        assert_eq!(
306            committed_lsn(&[a, GlobalId::User(4)], &uppers),
307            None,
308            "an export has not committed beyond the as_of",
309        );
310    }
311}