1use std::marker::PhantomData;
16
17use anyhow::Context;
18use risingwave_common::types::JsonbVal;
19use serde::{Deserialize, Serialize};
20
21use crate::error::ConnectorResult;
22use crate::source::cdc::external::DebeziumOffset;
23use crate::source::cdc::external::postgres::PostgresOffset;
24use crate::source::cdc::{CdcSourceType, CdcSourceTypeTrait, Mysql, Postgres, SqlServer};
25use crate::source::{SplitId, SplitMetaData};
26
27#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Hash)]
30pub struct CdcSplitBase {
31 pub split_id: u32,
32 pub start_offset: Option<String>,
33 pub snapshot_done: bool,
34}
35
36impl CdcSplitBase {
37 pub fn new(split_id: u32, start_offset: Option<String>) -> Self {
38 Self {
39 split_id,
40 start_offset,
41 snapshot_done: false,
42 }
43 }
44}
45
46trait CdcSplitTrait: Send + Sync {
47 fn split_id(&self) -> u32;
48 fn start_offset(&self) -> &Option<String>;
49 fn is_snapshot_done(&self) -> bool;
50 fn update_offset(&mut self, last_seen_offset: String) -> ConnectorResult<()>;
51
52 fn extract_snapshot_flag(&self, start_offset: &str) -> ConnectorResult<bool> {
54 let mut snapshot_done = self.is_snapshot_done();
56 if snapshot_done {
57 return Ok(snapshot_done);
58 }
59
60 let dbz_offset: DebeziumOffset = serde_json::from_str(start_offset).with_context(|| {
61 format!(
62 "invalid cdc offset: {}, split: {}",
63 start_offset,
64 self.split_id()
65 )
66 })?;
67
68 if !dbz_offset.is_heartbeat {
70 snapshot_done = match dbz_offset.source_offset.snapshot {
71 Some(val) => !val,
72 None => true,
73 };
74 }
75 Ok(snapshot_done)
76 }
77}
78
79#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Hash)]
80pub struct MySqlCdcSplit {
81 pub inner: CdcSplitBase,
82}
83
84#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Hash)]
85pub struct PostgresCdcSplit {
86 pub inner: CdcSplitBase,
87 pub server_addr: Option<String>,
89}
90
91#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Hash)]
92pub struct MongoDbCdcSplit {
93 pub inner: CdcSplitBase,
94}
95
96#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Hash)]
97pub struct SqlServerCdcSplit {
98 pub inner: CdcSplitBase,
99}
100
101#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Hash)]
102pub struct OracleCdcSplit {
103 pub inner: CdcSplitBase,
104}
105
106impl MySqlCdcSplit {
107 pub fn new(split_id: u32, start_offset: Option<String>) -> Self {
108 let split = CdcSplitBase {
109 split_id,
110 start_offset,
111 snapshot_done: false,
112 };
113 Self { inner: split }
114 }
115
116 pub fn mysql_binlog_offset(&self) -> Option<(u64, u64)> {
134 let offset_str = self.inner.start_offset.as_ref()?;
135 let offset = serde_json::from_str::<serde_json::Value>(offset_str).ok()?;
136 let source_offset = offset.get("sourceOffset")?;
137
138 let file = source_offset.get("file")?.as_str()?;
139 let pos = source_offset.get("pos")?.as_u64()?;
140
141 let file_seq = extract_binlog_file_seq(file)?;
142
143 Some((file_seq, pos))
144 }
145}
146
147impl CdcSplitTrait for MySqlCdcSplit {
148 fn split_id(&self) -> u32 {
149 self.inner.split_id
150 }
151
152 fn start_offset(&self) -> &Option<String> {
153 &self.inner.start_offset
154 }
155
156 fn is_snapshot_done(&self) -> bool {
157 self.inner.snapshot_done
158 }
159
160 fn update_offset(&mut self, last_seen_offset: String) -> ConnectorResult<()> {
161 self.inner.snapshot_done = self.extract_snapshot_flag(last_seen_offset.as_str())?;
163 self.inner.start_offset = Some(last_seen_offset);
164 Ok(())
165 }
166}
167
168impl PostgresCdcSplit {
169 pub fn new(split_id: u32, start_offset: Option<String>, server_addr: Option<String>) -> Self {
170 let split = CdcSplitBase {
171 split_id,
172 start_offset,
173 snapshot_done: false,
174 };
175 Self {
176 inner: split,
177 server_addr,
178 }
179 }
180
181 pub fn pg_lsn(&self) -> Option<u64> {
186 let offset_str = self.inner.start_offset.as_ref()?;
187 let offset = serde_json::from_str::<serde_json::Value>(offset_str).ok()?;
188 let source_offset = offset.get("sourceOffset")?;
189 let lsn = source_offset.get("lsn")?;
190 lsn.as_u64()
191 }
192
193 pub fn pg_lsn_commit(&self) -> Option<u64> {
195 let offset_str = self.inner.start_offset.as_ref()?;
196 extract_postgres_lsn_commit_from_offset_str(offset_str)
197 }
198
199 pub fn pg_lsn_proc(&self) -> Option<u64> {
201 let offset_str = self.inner.start_offset.as_ref()?;
202 extract_postgres_lsn_proc_from_offset_str(offset_str)
203 }
204}
205
206impl CdcSplitTrait for PostgresCdcSplit {
207 fn split_id(&self) -> u32 {
208 self.inner.split_id
209 }
210
211 fn start_offset(&self) -> &Option<String> {
212 &self.inner.start_offset
213 }
214
215 fn is_snapshot_done(&self) -> bool {
216 self.inner.snapshot_done
217 }
218
219 fn update_offset(&mut self, last_seen_offset: String) -> ConnectorResult<()> {
220 let new_snapshot_done = self.extract_snapshot_flag(last_seen_offset.as_str())?;
221
222 if let (Some(old_offset), Some(new_offset)) = (
257 self.inner
258 .start_offset
259 .as_deref()
260 .and_then(parse_postgres_offset_from_offset_str),
261 parse_postgres_offset_from_offset_str(&last_seen_offset),
262 ) && new_offset < old_offset
263 {
264 tracing::warn!(
265 split_id = self.inner.split_id,
266 ?old_offset,
267 ?new_offset,
268 "Rejecting backward Postgres CDC offset update; \
269 keeping current state to prevent state-table regression."
270 );
271 return Ok(());
272 }
273
274 self.inner.snapshot_done = new_snapshot_done;
275 self.inner.start_offset = Some(last_seen_offset);
276 Ok(())
277 }
278
279 fn extract_snapshot_flag(&self, start_offset: &str) -> ConnectorResult<bool> {
280 let mut snapshot_done = self.is_snapshot_done();
282 if snapshot_done {
283 return Ok(snapshot_done);
284 }
285
286 let dbz_offset: DebeziumOffset = serde_json::from_str(start_offset).with_context(|| {
287 format!(
288 "invalid postgres offset: {}, split: {}",
289 start_offset, self.inner.split_id
290 )
291 })?;
292
293 if !dbz_offset.is_heartbeat {
295 snapshot_done = dbz_offset
296 .source_offset
297 .last_snapshot_record
298 .unwrap_or(false);
299 }
300 Ok(snapshot_done)
301 }
302}
303
304impl MongoDbCdcSplit {
305 pub fn new(split_id: u32, start_offset: Option<String>) -> Self {
306 let split = CdcSplitBase {
307 split_id,
308 start_offset,
309 snapshot_done: false,
310 };
311 Self { inner: split }
312 }
313}
314
315impl CdcSplitTrait for MongoDbCdcSplit {
316 fn split_id(&self) -> u32 {
317 self.inner.split_id
318 }
319
320 fn start_offset(&self) -> &Option<String> {
321 &self.inner.start_offset
322 }
323
324 fn is_snapshot_done(&self) -> bool {
325 self.inner.snapshot_done
326 }
327
328 fn update_offset(&mut self, last_seen_offset: String) -> ConnectorResult<()> {
329 self.inner.snapshot_done = self.extract_snapshot_flag(last_seen_offset.as_str())?;
331 self.inner.start_offset = Some(last_seen_offset);
332 Ok(())
333 }
334}
335
336impl SqlServerCdcSplit {
337 pub fn new(split_id: u32, start_offset: Option<String>) -> Self {
338 let split = CdcSplitBase {
339 split_id,
340 start_offset,
341 snapshot_done: false,
342 };
343 Self { inner: split }
344 }
345
346 pub fn sql_server_change_lsn(&self) -> Option<u128> {
348 let offset_str = self.inner.start_offset.as_ref()?;
349 extract_sql_server_change_lsn_from_offset_str(offset_str)
350 }
351
352 pub fn sql_server_commit_lsn(&self) -> Option<u128> {
354 let offset_str = self.inner.start_offset.as_ref()?;
355 extract_sql_server_commit_lsn_from_offset_str(offset_str)
356 }
357}
358
359impl CdcSplitTrait for SqlServerCdcSplit {
360 fn split_id(&self) -> u32 {
361 self.inner.split_id
362 }
363
364 fn start_offset(&self) -> &Option<String> {
365 &self.inner.start_offset
366 }
367
368 fn is_snapshot_done(&self) -> bool {
369 self.inner.snapshot_done
370 }
371
372 fn update_offset(&mut self, last_seen_offset: String) -> ConnectorResult<()> {
373 self.inner.snapshot_done = self.extract_snapshot_flag(last_seen_offset.as_str())?;
375 self.inner.start_offset = Some(last_seen_offset);
376 Ok(())
377 }
378}
379
380impl OracleCdcSplit {
381 pub fn new(split_id: u32, start_offset: Option<String>) -> Self {
382 Self {
383 inner: CdcSplitBase::new(split_id, start_offset),
384 }
385 }
386}
387
388impl CdcSplitTrait for OracleCdcSplit {
389 fn split_id(&self) -> u32 {
390 self.inner.split_id
391 }
392
393 fn start_offset(&self) -> &Option<String> {
394 &self.inner.start_offset
395 }
396
397 fn is_snapshot_done(&self) -> bool {
398 self.inner.snapshot_done
399 }
400
401 fn update_offset(&mut self, last_seen_offset: String) -> ConnectorResult<()> {
402 self.inner.snapshot_done = self.extract_snapshot_flag(last_seen_offset.as_str())?;
403 self.inner.start_offset = Some(last_seen_offset);
404 Ok(())
405 }
406}
407
408#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Hash)]
410pub struct DebeziumCdcSplit<T: CdcSourceTypeTrait> {
411 pub mysql_split: Option<MySqlCdcSplit>,
412
413 #[serde(rename = "pg_split")] pub postgres_split: Option<PostgresCdcSplit>,
415 pub citus_split: Option<PostgresCdcSplit>,
416 pub mongodb_split: Option<MongoDbCdcSplit>,
417 pub sql_server_split: Option<SqlServerCdcSplit>,
418 pub oracle_split: Option<OracleCdcSplit>,
419
420 #[serde(skip)]
421 pub _phantom: PhantomData<T>,
422}
423
424macro_rules! dispatch_cdc_split_inner {
425 ($dbz_split:expr, $as_type:tt, {$({$cdc_source_type:tt, $cdc_source_split:tt}),*}, $body:expr) => {
426 match T::source_type() {
427 $(
428 CdcSourceType::$cdc_source_type => {
429 $crate::paste! {
430 $dbz_split.[<$cdc_source_split>]
431 .[<as_ $as_type>]()
432 .expect(concat!(stringify!([<$cdc_source_type:lower>]), " split must exist"))
433 .$body
434 }
435 }
436 )*
437 CdcSourceType::Unspecified => {
438 unreachable!("invalid debezium split");
439 }
440 }
441 }
442}
443
444macro_rules! dispatch_cdc_split {
446 ($dbz_split:expr, $as_type:tt, $body:expr) => {
447 dispatch_cdc_split_inner!($dbz_split, $as_type, {
448 {Mysql, mysql_split},
449 {Postgres, postgres_split},
450 {Citus, citus_split},
451 {Mongodb, mongodb_split},
452 {SqlServer, sql_server_split},
453 {Oracle, oracle_split}
454 }, $body)
455 }
456}
457
458impl<T: CdcSourceTypeTrait> SplitMetaData for DebeziumCdcSplit<T> {
459 fn id(&self) -> SplitId {
460 format!("{}", self.split_id()).into()
461 }
462
463 fn encode_to_json(&self) -> JsonbVal {
464 serde_json::to_value(self.clone()).unwrap().into()
465 }
466
467 fn restore_from_json(value: JsonbVal) -> ConnectorResult<Self> {
468 serde_json::from_value(value.take()).map_err(Into::into)
469 }
470
471 fn update_offset(&mut self, last_seen_offset: String) -> ConnectorResult<()> {
472 self.update_offset_inner(last_seen_offset)
473 }
474}
475
476impl<T: CdcSourceTypeTrait> DebeziumCdcSplit<T> {
477 pub fn new(split_id: u32, start_offset: Option<String>, server_addr: Option<String>) -> Self {
478 let mut ret = Self {
479 mysql_split: None,
480 postgres_split: None,
481 citus_split: None,
482 mongodb_split: None,
483 sql_server_split: None,
484 oracle_split: None,
485 _phantom: PhantomData,
486 };
487 match T::source_type() {
488 CdcSourceType::Mysql => {
489 let split = MySqlCdcSplit::new(split_id, start_offset);
490 ret.mysql_split = Some(split);
491 }
492 CdcSourceType::Postgres => {
493 let split = PostgresCdcSplit::new(split_id, start_offset, None);
494 ret.postgres_split = Some(split);
495 }
496 CdcSourceType::Citus => {
497 let split = PostgresCdcSplit::new(split_id, start_offset, server_addr);
498 ret.citus_split = Some(split);
499 }
500 CdcSourceType::Mongodb => {
501 let split = MongoDbCdcSplit::new(split_id, start_offset);
502 ret.mongodb_split = Some(split);
503 }
504 CdcSourceType::SqlServer => {
505 let split = SqlServerCdcSplit::new(split_id, start_offset);
506 ret.sql_server_split = Some(split);
507 }
508 CdcSourceType::Oracle => {
509 let split = OracleCdcSplit::new(split_id, start_offset);
510 ret.oracle_split = Some(split);
511 }
512 CdcSourceType::Unspecified => {
513 unreachable!("invalid debezium split")
514 }
515 }
516 ret
517 }
518
519 pub fn split_id(&self) -> u32 {
520 dispatch_cdc_split!(self, ref, split_id())
521 }
522
523 pub fn start_offset(&self) -> &Option<String> {
524 dispatch_cdc_split!(self, ref, start_offset())
525 }
526
527 pub fn snapshot_done(&self) -> bool {
528 dispatch_cdc_split!(self, ref, is_snapshot_done())
529 }
530
531 pub fn update_offset_inner(&mut self, last_seen_offset: String) -> ConnectorResult<()> {
532 dispatch_cdc_split!(self, mut, update_offset(last_seen_offset)?);
533 Ok(())
534 }
535}
536
537impl DebeziumCdcSplit<Postgres> {
538 pub fn pg_lsn(&self) -> Option<u64> {
542 self.postgres_split.as_ref()?.pg_lsn()
543 }
544}
545
546impl DebeziumCdcSplit<Mysql> {
547 pub fn mysql_binlog_offset(&self) -> Option<(u64, u64)> {
553 self.mysql_split.as_ref()?.mysql_binlog_offset()
554 }
555}
556
557impl DebeziumCdcSplit<SqlServer> {
558 pub fn sql_server_change_lsn(&self) -> Option<u128> {
560 self.sql_server_split.as_ref()?.sql_server_change_lsn()
561 }
562
563 pub fn sql_server_commit_lsn(&self) -> Option<u128> {
565 self.sql_server_split.as_ref()?.sql_server_commit_lsn()
566 }
567}
568
569pub fn extract_binlog_file_seq(file_name: &str) -> Option<u64> {
574 file_name.rsplit('.').next()?.parse::<u64>().ok()
575}
576
577pub fn extract_postgres_lsn_from_offset_str(offset_str: &str) -> Option<u64> {
584 let offset = serde_json::from_str::<serde_json::Value>(offset_str).ok()?;
585 let source_offset = offset.get("sourceOffset")?;
586 let lsn = source_offset.get("lsn")?;
587 lsn.as_u64()
588}
589
590fn parse_postgres_offset_from_offset_str(offset_str: &str) -> Option<PostgresOffset> {
591 PostgresOffset::parse_debezium_offset(offset_str).ok()
592}
593
594pub fn extract_postgres_lsn_commit_from_offset_str(offset_str: &str) -> Option<u64> {
603 let offset = serde_json::from_str::<serde_json::Value>(offset_str).ok()?;
604 let source_offset = offset.get("sourceOffset")?;
605 let lsn = source_offset.get("lsn_commit")?;
606 lsn.as_u64()
607}
608
609pub fn extract_postgres_lsn_proc_from_offset_str(offset_str: &str) -> Option<u64> {
617 let offset = serde_json::from_str::<serde_json::Value>(offset_str).ok()?;
618 let source_offset = offset.get("sourceOffset")?;
619 let lsn = source_offset.get("lsn_proc")?;
620 lsn.as_u64()
621}
622
623pub fn parse_sql_server_lsn_str(lsn: &str) -> Option<u128> {
625 let mut parts = lsn.split(':');
626 let part0 = u32::from_str_radix(parts.next()?, 16).ok()? as u128;
627 let part1 = u32::from_str_radix(parts.next()?, 16).ok()? as u128;
628 let part2 = u16::from_str_radix(parts.next()?, 16).ok()? as u128;
629 if parts.next().is_some() {
630 return None;
631 }
632
633 Some((part0 << 48) | (part1 << 16) | part2)
634}
635
636pub fn extract_sql_server_change_lsn_from_offset_str(offset_str: &str) -> Option<u128> {
638 let offset = serde_json::from_str::<serde_json::Value>(offset_str).ok()?;
639 let source_offset = offset.get("sourceOffset")?;
640 let lsn = source_offset.get("change_lsn")?.as_str()?;
641 parse_sql_server_lsn_str(lsn)
642}
643
644pub fn extract_sql_server_commit_lsn_from_offset_str(offset_str: &str) -> Option<u128> {
646 let offset = serde_json::from_str::<serde_json::Value>(offset_str).ok()?;
647 let source_offset = offset.get("sourceOffset")?;
648 let lsn = source_offset.get("commit_lsn")?.as_str()?;
649 parse_sql_server_lsn_str(lsn)
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn test_parse_sql_server_lsn_str() {
658 let lsn = "00000027:00000ac0:0002";
659 let parsed = parse_sql_server_lsn_str(lsn).unwrap();
660 let expected = ((0x00000027_u128) << 48) | ((0x00000ac0_u128) << 16) | (0x0002_u128);
661 assert_eq!(parsed, expected);
662 }
663
664 fn pg_streaming_offset_json(lsn: u64) -> String {
668 pg_streaming_offset_json_full(lsn, lsn, lsn)
669 }
670
671 fn pg_streaming_offset_json_full(lsn: u64, lsn_proc: u64, lsn_commit: u64) -> String {
675 format!(
676 r#"{{
677 "sourcePartition": {{"server": "RW_CDC_1001"}},
678 "sourceOffset": {{
679 "last_snapshot_record": false,
680 "lsn": {lsn},
681 "lsn_proc": {lsn_proc},
682 "lsn_commit": {lsn_commit},
683 "txId": 12345,
684 "ts_usec": 1700000000000000
685 }},
686 "isHeartbeat": false
687 }}"#
688 )
689 }
690
691 #[test]
692 fn test_postgres_offset_monotonic_guard_rejects_backward_lsn() {
693 let mut split = PostgresCdcSplit::new(1, Some(pg_streaming_offset_json(200)), None);
694
695 split
697 .update_offset(pg_streaming_offset_json(150))
698 .expect("update_offset must not error on rejection");
699 assert_eq!(split.pg_lsn(), Some(200));
700 }
701
702 #[test]
703 fn test_postgres_offset_monotonic_guard_allows_forward_lsn() {
704 let mut split = PostgresCdcSplit::new(1, Some(pg_streaming_offset_json(200)), None);
705
706 split.update_offset(pg_streaming_offset_json(250)).unwrap();
707 assert_eq!(split.pg_lsn(), Some(250));
708 }
709
710 #[test]
711 fn test_postgres_offset_monotonic_guard_allows_equal_lsn() {
712 let mut split = PostgresCdcSplit::new(1, Some(pg_streaming_offset_json(200)), None);
713
714 split.update_offset(pg_streaming_offset_json(200)).unwrap();
716 assert_eq!(split.pg_lsn(), Some(200));
717 }
718
719 #[test]
720 fn test_postgres_offset_snapshot_phase_bypasses_guard() {
721 let snapshot_offset = r#"{
722 "sourcePartition": {"server": "RW_CDC_1001"},
723 "sourceOffset": {
724 "last_snapshot_record": false,
725 "lsn": 150,
726 "txId": 12345,
727 "ts_usec": 1700000000000000
728 },
729 "isHeartbeat": false
730 }"#;
731 let mut split = PostgresCdcSplit::new(1, Some(pg_streaming_offset_json(200)), None);
732
733 split.update_offset(snapshot_offset.to_owned()).unwrap();
736 assert_eq!(split.pg_lsn(), Some(150));
737 }
738
739 #[test]
740 fn test_postgres_offset_lsn_commit_regression_rejected() {
741 let initial = pg_streaming_offset_json_full(200, 200, 200);
745 let mut split = PostgresCdcSplit::new(1, Some(initial), None);
746
747 let new = pg_streaming_offset_json_full(200, 200, 150);
749 split.update_offset(new).unwrap();
750
751 assert_eq!(
752 split.pg_lsn_commit(),
753 Some(200),
754 "lsn_commit must not regress"
755 );
756 }
757
758 #[test]
759 fn test_postgres_offset_lsn_proc_regression_rejected() {
760 let initial = pg_streaming_offset_json_full(200, 200, 200);
764 let mut split = PostgresCdcSplit::new(1, Some(initial), None);
765
766 let new = pg_streaming_offset_json_full(200, 150, 200);
768 split.update_offset(new).unwrap();
769
770 assert_eq!(split.pg_lsn_proc(), Some(200), "lsn_proc must not regress");
771 }
772
773 #[test]
774 fn test_postgres_offset_interleaved_tx_lsn_proc_drop_allowed_by_commit_lsn() {
775 let initial = pg_streaming_offset_json_full(33_967_592, 33_967_592, 33_893_392);
786 let mut split = PostgresCdcSplit::new(1, Some(initial), None);
787
788 let new = pg_streaming_offset_json_full(33_918_336, 33_918_336, 33_969_832);
789 split.update_offset(new).unwrap();
790
791 assert_eq!(split.pg_lsn_commit(), Some(33_969_832));
792 assert_eq!(split.pg_lsn_proc(), Some(33_918_336));
793 }
794
795 #[test]
796 fn test_extract_binlog_file_seq() {
797 assert_eq!(extract_binlog_file_seq("binlog.000123"), Some(123));
799 assert_eq!(extract_binlog_file_seq("mysql-bin.000123"), Some(123));
801 assert_eq!(extract_binlog_file_seq("my-host-bin.000001"), Some(1));
803 assert_eq!(
805 extract_binlog_file_seq("mysql-bin-changelog.037568"),
806 Some(37568)
807 );
808 assert_eq!(extract_binlog_file_seq("binlog.index"), None);
810 assert_eq!(extract_binlog_file_seq("no-extension"), None);
811 }
812
813 #[test]
814 fn test_mysql_binlog_offset() {
815 let offset = r#"{
816 "sourcePartition": {"server": "test"},
817 "sourceOffset": {
818 "file": "mysql-bin-changelog.037568",
819 "pos": 12345
820 },
821 "isHeartbeat": false
822 }"#;
823 let split = MySqlCdcSplit::new(1, Some(offset.to_owned()));
824 assert_eq!(split.mysql_binlog_offset(), Some((37568, 12345)));
825 }
826
827 #[test]
828 fn test_extract_sql_server_lsn_from_offset_str() {
829 let offset = r#"{
830 "sourcePartition": {"server":"RW_CDC_1001"},
831 "sourceOffset": {
832 "change_lsn":"00000027:00000ac0:0001",
833 "commit_lsn":"00000027:00000ac0:0002"
834 },
835 "isHeartbeat": false
836 }"#;
837
838 let change_lsn = extract_sql_server_change_lsn_from_offset_str(offset).unwrap();
839 let commit_lsn = extract_sql_server_commit_lsn_from_offset_str(offset).unwrap();
840 assert!(change_lsn < commit_lsn);
841 }
842}