Skip to content

Commit 946b68f

Browse files
authored
SS-350: sql-server: seed offset_committed from resume LSN on startup (#37680)
The progress operator left offset_committed at 0 until the first resume upper arrived, so ingestion lag read as the full upstream LSN during the initial snapshot. Initialize it from the resumption LSN, or max upstream LSN, if snapshotting. Initialize offset_known concurrently to further reduce the gap where these 2 could result in bogus lag calculation.
1 parent 4148d5b commit 946b68f

6 files changed

Lines changed: 267 additions & 11 deletions

File tree

src/storage/src/source/sql_server.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ use timely::dataflow::operators::Concat;
3232
use timely::dataflow::operators::core::Partition;
3333
use timely::dataflow::operators::vec::{Map, ToStream};
3434
use timely::dataflow::{Scope, StreamVec};
35-
use timely::progress::Antichain;
35+
use timely::progress::{Antichain, Timestamp};
3636

3737
use crate::healthcheck::{HealthStatusMessage, HealthStatusUpdate, StatusNamespace};
3838
use crate::source::RawSourceCreationConfig;
@@ -58,6 +58,20 @@ struct SourceOutputInfo {
5858
initial_lsn: Lsn,
5959
}
6060

61+
impl SourceOutputInfo {
62+
/// The [`Lsn`] this output resumes reading from, or the provided fallback [`Lsn`].
63+
///
64+
/// Panics if `resume_upper` is empty, which would mean the output has no
65+
/// resumption point at all.
66+
fn resume_lsn_or(&self, fallback: Lsn) -> Lsn {
67+
match self.resume_upper.as_option() {
68+
Some(lsn) if *lsn != Lsn::minimum() => *lsn,
69+
Some(_) => fallback,
70+
None => panic!("resume_upper has at least one value"),
71+
}
72+
}
73+
}
74+
6175
#[derive(Debug, Clone, thiserror::Error)]
6276
pub enum ReplicationError {
6377
#[error(transparent)]

src/storage/src/source/sql_server/progress.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ pub(crate) fn render<'scope>(
8585
}
8686
return Ok(());
8787
}
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.
8893
let conn_config = connection
8994
.resolve_config(
9095
&config.config.connection_context.secrets_reader,
@@ -93,6 +98,32 @@ pub(crate) fn render<'scope>(
9398
)
9499
.await?;
95100
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+
}
96127

97128

98129
// Terminate the progress probes if a restore has happened. Replication operator will
@@ -194,8 +225,14 @@ pub(crate) fn render<'scope>(
194225
}
195226
}
196227
}
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+
}
197234
for stat in config.statistics.values() {
198-
stat.set_offset_committed(committed_upper.abbreviate());
235+
stat.set_offset_committed(max_committed_lsn.abbreviate());
199236
}
200237
}
201238
};

src/storage/src/source/sql_server/replication.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -391,14 +391,10 @@ pub(crate) fn render<'scope>(
391391
// Resumption point is the minimum LSN that has been observed per capture instance.
392392
let mut resume_lsns = BTreeMap::new();
393393
for src_info in outputs.values() {
394-
let resume_lsn = match src_info.resume_upper.as_option() {
395-
Some(lsn) if *lsn != Lsn::minimum() => *lsn,
396-
// initial_lsn is the max lsn observed, but the resume lsn
397-
// is the next lsn that should be read. After a snapshot, initial_lsn
398-
// has been read, so replication will start at the next available lsn.
399-
Some(_) => src_info.initial_lsn.increment(),
400-
None => panic!("resume_upper has at least one value"),
401-
};
394+
// initial_lsn is the max lsn observed, but the resume lsn
395+
// is the next lsn that should be read. After a snapshot, initial_lsn
396+
// has been read, so replication will start at the next available lsn.
397+
let resume_lsn = src_info.resume_lsn_or(src_info.initial_lsn.increment());
402398
resume_lsns.entry(Arc::clone(&src_info.capture_instance))
403399
.and_modify(|existing| *existing = std::cmp::min(*existing, resume_lsn))
404400
.or_insert(resume_lsn);
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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 at the root of this repository.
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+
#
11+
# Regression test: a freshly created source must report a near-zero ingestion
12+
# lag, even while its upstream database is idle.
13+
#
14+
# The two offset statistics must be initialized from a common validated
15+
# upstream point. Leaving `offset_committed` at 0 after `offset_known` advances
16+
# produces an enormous lag. Publishing the full resumption LSN before the known
17+
# frontier catches up reverses the ordering and makes the same lag calculation
18+
# (`offset_known - offset_committed`) underflow.
19+
#
20+
# A newly enabled capture instance can start ahead of the database-wide CDC
21+
# maximum. Purification must use that higher start LSN as the source's resumption
22+
# point. The progress probe still observes the lower database maximum. We stop
23+
# this database's capture job before enabling the target table so the two LSNs
24+
# cannot converge and mask the invalid statistics.
25+
26+
$ sql-server-connect name=sql-server
27+
server=tcp:sql-server,1433;IntegratedSecurity=true;TrustServerCertificate=true;User ID=${arg.default-sql-server-user};Password=${arg.default-sql-server-password}
28+
29+
$ sql-server-execute name=sql-server split-lines=false
30+
IF EXISTS (SELECT name FROM sys.databases WHERE name = N'lag_test')
31+
BEGIN
32+
ALTER DATABASE lag_test SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
33+
DROP DATABASE lag_test;
34+
END;
35+
36+
$ sql-server-execute name=sql-server
37+
CREATE DATABASE lag_test COLLATE Latin1_General_100_CI_AI_SC_UTF8;
38+
USE lag_test;
39+
EXEC sys.sp_cdc_enable_db;
40+
ALTER DATABASE lag_test SET ALLOW_SNAPSHOT_ISOLATION ON;
41+
42+
$ sql-server-execute name=sql-server
43+
USE lag_test;
44+
CREATE TABLE lag_seed (f1 VARCHAR(20));
45+
EXEC sys.sp_cdc_enable_table @source_schema = 'dbo', @source_name = 'lag_seed', @role_name = 'SA', @supports_net_changes = 0;
46+
INSERT INTO lag_seed VALUES ('seed');
47+
48+
# Establish a nonzero database maximum, then freeze it. Enabling `lag_t` after
49+
# this point gives its capture instance a later start LSN.
50+
$ sql-server-execute name=sql-server split-lines=false
51+
WAITFOR DELAY '00:00:20';
52+
53+
$ sql-server-execute name=sql-server
54+
USE lag_test;
55+
EXEC sys.sp_cdc_stop_job @job_type = 'capture';
56+
CREATE TABLE lag_t (f1 VARCHAR(20));
57+
EXEC sys.sp_cdc_enable_table @source_schema = 'dbo', @source_name = 'lag_t', @role_name = 'SA', @supports_net_changes = 0;
58+
INSERT INTO lag_t VALUES ('one'), ('two'), ('three');
59+
60+
# This is the upstream state the test depends on. If SQL Server changes its CDC
61+
# behavior, fail here rather than attributing a later result to Materialize.
62+
$ sql-server-execute name=sql-server split-lines=false
63+
IF NOT EXISTS (
64+
SELECT 1
65+
FROM lag_test.cdc.change_tables
66+
WHERE capture_instance = 'dbo_lag_t'
67+
AND start_lsn > lag_test.sys.fn_cdc_get_max_lsn()
68+
)
69+
THROW 50000, 'expected capture start LSN above database maximum', 1;
70+
71+
$ postgres-execute connection=postgres://mz_system:materialize@${testdrive.materialize-internal-sql-addr}
72+
ALTER SYSTEM SET storage_statistics_collection_interval = 1000;
73+
ALTER SYSTEM SET storage_statistics_interval = 2000;
74+
75+
> CREATE SECRET IF NOT EXISTS mspass AS '${arg.default-sql-server-password}'
76+
77+
> CREATE CONNECTION msconn TO SQL SERVER (
78+
HOST 'sql-server',
79+
PORT 1433,
80+
DATABASE lag_test,
81+
USER '${arg.default-sql-server-user}',
82+
PASSWORD = SECRET mspass
83+
);
84+
85+
> CREATE CLUSTER lag_cluster SIZE '${arg.default-replica-size}'
86+
> CREATE SOURCE lag_source IN CLUSTER lag_cluster FROM SQL SERVER CONNECTION msconn;
87+
> CREATE TABLE lag_t FROM SOURCE lag_source (REFERENCE dbo.lag_t);
88+
89+
# We intentionally do not wait for the snapshot to become queryable. On an idle
90+
# database the source frontier only advances once SQL Server advances its max
91+
# LSN, which can take minutes. That frontier advance is also what would fix a
92+
# buggy `offset_committed`, so waiting for it would mask the bug. Instead we
93+
# assert on the statistics, which are populated within seconds regardless of the
94+
# frontier.
95+
96+
# Ensure statistics for the source's replica exist before pinning the replica
97+
# id below, which does not retry on a missing row.
98+
> SELECT COUNT(*) > 0
99+
FROM
100+
mz_cluster_replicas cr,
101+
mz_internal.mz_source_statistics u,
102+
mz_sources s
103+
WHERE
104+
cr.id = u.replica_id AND s.name = 'lag_source' AND u.id = s.id
105+
true
106+
107+
$ set-from-sql var=replica_id
108+
SELECT cr.id
109+
FROM
110+
mz_clusters c,
111+
mz_cluster_replicas cr,
112+
mz_internal.mz_source_statistics u,
113+
mz_sources s
114+
WHERE
115+
c.name = 'lag_cluster' AND c.id = cr.cluster_id AND cr.id = u.replica_id
116+
AND s.name = 'lag_source' AND u.id = s.id
117+
ORDER BY cr.id
118+
LIMIT 1
119+
120+
# The reported committed offset must not exceed the first known upstream offset,
121+
# even though the actual resumption LSN does. The second expression is the
122+
# ingestion-lag calculation used by customers. Since both columns are `uint8`,
123+
# invalid ordering makes the subtraction itself fail instead of reporting lag.
124+
$ set-sql-timeout duration=60s
125+
126+
> SELECT
127+
u.offset_committed > 0,
128+
u.offset_known >= u.offset_committed,
129+
u.offset_known - u.offset_committed < 1000000
130+
FROM mz_sources s
131+
JOIN mz_internal.mz_source_statistics u ON s.id = u.id
132+
WHERE s.name = 'lag_source' AND u.replica_id = '${replica_id}'
133+
true true true
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 at the root of this repository.
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+
# Hold the first runtime connection open and verify that the ingestion-lag
11+
# calculation remains valid before `offset_known` is initialized.
12+
13+
$ sql-server-connect name=sql-server
14+
server=tcp:sql-server,1433;IntegratedSecurity=true;TrustServerCertificate=true;User ID=${arg.default-sql-server-user};Password=${arg.default-sql-server-password}
15+
16+
$ sql-server-execute name=sql-server
17+
USE test;
18+
CREATE TABLE t73 (f1 VARCHAR(20));
19+
EXEC sys.sp_cdc_enable_table @source_schema = 'dbo', @source_name = 't73', @role_name = 'SA', @supports_net_changes = 0;
20+
INSERT INTO t73 VALUES ('one');
21+
22+
$ http-request method=POST url=http://toxiproxy:8474/proxies content-type=application/json
23+
{
24+
"name": "sql-server",
25+
"listen": "0.0.0.0:1433",
26+
"upstream": "sql-server:1433",
27+
"enabled": true
28+
}
29+
30+
$ postgres-execute connection=postgres://mz_system:materialize@${testdrive.materialize-internal-sql-addr}
31+
ALTER SYSTEM SET storage_statistics_collection_interval = 1000;
32+
ALTER SYSTEM SET storage_statistics_interval = 2000;
33+
34+
> CREATE CLUSTER storage REPLICAS ()
35+
> CREATE SECRET pass AS '${arg.default-sql-server-password}'
36+
> CREATE CONNECTION conn TO SQL SERVER (
37+
HOST 'toxiproxy',
38+
PORT 1433,
39+
DATABASE test,
40+
USER '${arg.default-sql-server-user}',
41+
PASSWORD = SECRET pass
42+
)
43+
> CREATE SOURCE s IN CLUSTER storage
44+
FROM SQL SERVER CONNECTION conn
45+
FOR TABLES (dbo.t73)
46+
47+
# Stall the first connection past several statistics intervals.
48+
$ http-request method=POST url=http://toxiproxy:8474/proxies/sql-server/toxics content-type=application/json
49+
{
50+
"name": "startup-delay",
51+
"type": "latency",
52+
"attributes": { "latency": 30000 }
53+
}
54+
55+
> CREATE CLUSTER REPLICA storage.r1 SIZE = 'scale=1,workers=1'
56+
57+
# The correct state remains zero, so there is no nonzero value to poll for.
58+
$ sleep-is-probably-flaky-i-have-justified-my-need-with-a-comment duration="5s"
59+
60+
$ set-sql-timeout duration=10s
61+
62+
> SELECT u.offset_known - u.offset_committed
63+
FROM mz_sources s
64+
JOIN mz_internal.mz_source_statistics u ON s.id = u.id
65+
WHERE s.name = 's'
66+
0

test/sql-server-cdc/mzcompose.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from materialize.mzcompose.services.sql_server import SqlServer
3030
from materialize.mzcompose.services.test_certs import TestCerts
3131
from materialize.mzcompose.services.testdrive import Testdrive
32+
from materialize.mzcompose.services.toxiproxy import Toxiproxy
3233

3334
TLS_CONF_PATH = MZ_ROOT / "test" / "sql-server-cdc" / "tls-mssconfig.conf"
3435

@@ -41,6 +42,7 @@
4142
),
4243
Testdrive(),
4344
TestCerts(),
45+
Toxiproxy(),
4446
SqlServer(
4547
volumes_extra=[
4648
"secrets:/var/opt/mssql/certs",
@@ -122,10 +124,18 @@ def workflow_cdc(c: Composition, parser: WorkflowArgumentParser) -> None:
122124
c.rm("sql-server")
123125
c.kill("materialized")
124126
c.rm("materialized")
127+
c.kill("toxiproxy")
128+
c.rm("toxiproxy")
125129

126130
# must start test-certs, otherwise the certificates needed by sql-server may not be available
127131
# in the secrets volume when it starts up
128-
c.up("materialized", "test-certs", "sql-server", Service("testdrive", idle=True))
132+
c.up(
133+
"materialized",
134+
"test-certs",
135+
"sql-server",
136+
"toxiproxy",
137+
Service("testdrive", idle=True),
138+
)
129139
seed = random.getrandbits(16)
130140

131141
ssl_ca = c.exec(

0 commit comments

Comments
 (0)