@@ -1056,6 +1056,223 @@ def test_incremental_parent_state_no_incremental_dependency(
10561056 )
10571057
10581058
1059+ # Fresh initial sync of the ``post_comment_votes`` global-cursor substream whose parent
1060+ # (``post_comments``) has no ``incremental_dependency`` -- i.e. the ``bank_accounts`` shape
1061+ # from OC-12977. Every child request filters on the config ``start_date`` because the global
1062+ # cursor is empty/unadvanced during the walk, so the exact same URLs answer both the initial
1063+ # read and any resume-from-interim-checkpoint read.
1064+ GLOBAL_CURSOR_RESUME_MOCK_REQUESTS = [
1065+ # Posts page 1
1066+ (
1067+ f"https://api.example.com/community/posts?per_page=100&start_time={ START_DATE } " ,
1068+ {
1069+ "posts" : [
1070+ {"id" : 1 , "updated_at" : POST_1_UPDATED_AT },
1071+ {"id" : 2 , "updated_at" : POST_2_UPDATED_AT },
1072+ ],
1073+ "next_page" : f"https://api.example.com/community/posts?per_page=100&start_time={ START_DATE } &page=2" ,
1074+ },
1075+ ),
1076+ # Posts page 2
1077+ (
1078+ f"https://api.example.com/community/posts?per_page=100&start_time={ START_DATE } &page=2" ,
1079+ {"posts" : [{"id" : 3 , "updated_at" : POST_3_UPDATED_AT }]},
1080+ ),
1081+ # Comments for post 1 page 1
1082+ (
1083+ "https://api.example.com/community/posts/1/comments?per_page=100" ,
1084+ {
1085+ "comments" : [
1086+ {"id" : 9 , "post_id" : 1 , "updated_at" : COMMENT_9_OLDEST },
1087+ {"id" : 10 , "post_id" : 1 , "updated_at" : COMMENT_10_UPDATED_AT },
1088+ {"id" : 11 , "post_id" : 1 , "updated_at" : COMMENT_11_UPDATED_AT },
1089+ ],
1090+ "next_page" : "https://api.example.com/community/posts/1/comments?per_page=100&page=2" ,
1091+ },
1092+ ),
1093+ # Comments for post 1 page 2
1094+ (
1095+ "https://api.example.com/community/posts/1/comments?per_page=100&page=2" ,
1096+ {"comments" : [{"id" : 12 , "post_id" : 1 , "updated_at" : COMMENT_12_UPDATED_AT }]},
1097+ ),
1098+ # Votes for comment 10 page 1
1099+ (
1100+ f"https://api.example.com/community/posts/1/comments/10/votes?per_page=100&start_time={ START_DATE } " ,
1101+ {
1102+ "votes" : [{"id" : 100 , "comment_id" : 10 , "created_at" : VOTE_100_CREATED_AT }],
1103+ "next_page" : f"https://api.example.com/community/posts/1/comments/10/votes?per_page=100&page=2&start_time={ START_DATE } " ,
1104+ },
1105+ ),
1106+ # Votes for comment 10 page 2
1107+ (
1108+ f"https://api.example.com/community/posts/1/comments/10/votes?per_page=100&page=2&start_time={ START_DATE } " ,
1109+ {"votes" : [{"id" : 101 , "comment_id" : 10 , "created_at" : VOTE_101_CREATED_AT }]},
1110+ ),
1111+ # Votes for comment 11
1112+ (
1113+ f"https://api.example.com/community/posts/1/comments/11/votes?per_page=100&start_time={ START_DATE } " ,
1114+ {"votes" : [{"id" : 111 , "comment_id" : 11 , "created_at" : VOTE_111_CREATED_AT }]},
1115+ ),
1116+ # Votes for comment 12 (empty child partition)
1117+ (
1118+ f"https://api.example.com/community/posts/1/comments/12/votes?per_page=100&start_time={ START_DATE } " ,
1119+ {"votes" : []},
1120+ ),
1121+ # Comments for post 2 page 1
1122+ (
1123+ "https://api.example.com/community/posts/2/comments?per_page=100" ,
1124+ {
1125+ "comments" : [{"id" : 20 , "post_id" : 2 , "updated_at" : COMMENT_20_UPDATED_AT }],
1126+ "next_page" : "https://api.example.com/community/posts/2/comments?per_page=100&page=2" ,
1127+ },
1128+ ),
1129+ # Comments for post 2 page 2
1130+ (
1131+ "https://api.example.com/community/posts/2/comments?per_page=100&page=2" ,
1132+ {"comments" : [{"id" : 21 , "post_id" : 2 , "updated_at" : COMMENT_21_UPDATED_AT }]},
1133+ ),
1134+ # Votes for comment 20
1135+ (
1136+ f"https://api.example.com/community/posts/2/comments/20/votes?per_page=100&start_time={ START_DATE } " ,
1137+ {"votes" : [{"id" : 200 , "comment_id" : 20 , "created_at" : VOTE_200_CREATED_AT }]},
1138+ ),
1139+ # Votes for comment 21
1140+ (
1141+ f"https://api.example.com/community/posts/2/comments/21/votes?per_page=100&start_time={ START_DATE } " ,
1142+ {"votes" : [{"id" : 210 , "comment_id" : 21 , "created_at" : VOTE_210_CREATED_AT }]},
1143+ ),
1144+ # Comments for post 3
1145+ (
1146+ "https://api.example.com/community/posts/3/comments?per_page=100" ,
1147+ {"comments" : [{"id" : 30 , "post_id" : 3 , "updated_at" : COMMENT_30_UPDATED_AT }]},
1148+ ),
1149+ # Votes for comment 30
1150+ (
1151+ f"https://api.example.com/community/posts/3/comments/30/votes?per_page=100&start_time={ START_DATE } " ,
1152+ {"votes" : [{"id" : 300 , "comment_id" : 30 , "created_at" : VOTE_300_CREATED_AT_TIMESTAMP }]},
1153+ ),
1154+ ]
1155+
1156+ GLOBAL_CURSOR_RESUME_EXPECTED_RECORDS = [
1157+ {
1158+ "comment_id" : 10 ,
1159+ "comment_updated_at" : COMMENT_10_UPDATED_AT ,
1160+ "created_at" : VOTE_100_CREATED_AT ,
1161+ "id" : 100 ,
1162+ },
1163+ {
1164+ "comment_id" : 10 ,
1165+ "comment_updated_at" : COMMENT_10_UPDATED_AT ,
1166+ "created_at" : VOTE_101_CREATED_AT ,
1167+ "id" : 101 ,
1168+ },
1169+ {
1170+ "comment_id" : 11 ,
1171+ "comment_updated_at" : COMMENT_11_UPDATED_AT ,
1172+ "created_at" : VOTE_111_CREATED_AT ,
1173+ "id" : 111 ,
1174+ },
1175+ {
1176+ "comment_id" : 20 ,
1177+ "comment_updated_at" : COMMENT_20_UPDATED_AT ,
1178+ "created_at" : VOTE_200_CREATED_AT ,
1179+ "id" : 200 ,
1180+ },
1181+ {
1182+ "comment_id" : 21 ,
1183+ "comment_updated_at" : COMMENT_21_UPDATED_AT ,
1184+ "created_at" : VOTE_210_CREATED_AT ,
1185+ "id" : 210 ,
1186+ },
1187+ {
1188+ "comment_id" : 30 ,
1189+ "comment_updated_at" : COMMENT_30_UPDATED_AT ,
1190+ "created_at" : str (VOTE_300_CREATED_AT_TIMESTAMP ),
1191+ "id" : 300 ,
1192+ },
1193+ ]
1194+
1195+
1196+ def test_global_cursor_substream_resume_after_interruption_skips_no_records ():
1197+ """Interrupt a global-cursor substream mid-walk, resume from every interim checkpoint, and prove no records are skipped.
1198+
1199+ Regression test for OC-12977. A ``global_substream_cursor`` substream whose parent has no
1200+ ``incremental_dependency`` (empty parent state -- the ``bank_accounts`` shape) now emits its
1201+ throttled checkpoint STATE during the walk because the empty-parent guard was removed. This
1202+ test proves those interim checkpoints are *safe to resume from*: for each interim STATE the
1203+ cursor emits, we simulate the source being killed right after it (keeping only the records
1204+ emitted before that STATE), then start a fresh read seeded with that STATE and assert the
1205+ union of pre-kill records and resume records covers the full expected set with nothing
1206+ skipped. Because the interim global cursor is inert until the sync finishes, resuming
1207+ re-reads rather than skips -- which is exactly the no-data-loss guarantee we want.
1208+ """
1209+ # Disable the 600s throttle so an interim checkpoint STATE is emitted at every close_partition,
1210+ # giving us many mid-walk "kill" points to resume from.
1211+ with patch .object (
1212+ ConcurrentPerPartitionCursor , "_throttle_state_message" , return_value = 9999999.0
1213+ ):
1214+ with requests_mock .Mocker () as m :
1215+ for url , response in GLOBAL_CURSOR_RESUME_MOCK_REQUESTS :
1216+ m .get (url , json = response )
1217+
1218+ # Fresh initial sync (no incoming state), interrupted implicitly by inspecting
1219+ # every interim checkpoint it emits.
1220+ output = _run_read (
1221+ SUBSTREAM_MANIFEST_WITH_GLOBAL_CURSOR_AND_NO_DEPENDENCY ,
1222+ CONFIG ,
1223+ STREAM_NAME ,
1224+ None ,
1225+ )
1226+
1227+ # Sanity: the uninterrupted run yields the full expected record set.
1228+ assert sorted ([r .record .data for r in output .records ], key = lambda x : x ["id" ]) == sorted (
1229+ GLOBAL_CURSOR_RESUME_EXPECTED_RECORDS , key = lambda x : x ["id" ]
1230+ )
1231+
1232+ # The fix must produce at least one interim checkpoint STATE during the walk
1233+ # (before the final state) -- otherwise there is nothing to resume from.
1234+ assert len (output .state_messages ) >= 2 , (
1235+ "Expected at least one interim checkpoint STATE plus the final STATE; "
1236+ f"got { len (output .state_messages )} state message(s)."
1237+ )
1238+
1239+ # Map each interim STATE to the records emitted before it (what the destination
1240+ # would have committed had the source been killed right after that checkpoint).
1241+ cumulative_records = []
1242+ interim_checkpoints = []
1243+ for message in output .records_and_state_messages :
1244+ if message .type .value == "RECORD" :
1245+ cumulative_records .append (message .record .data )
1246+ elif message .type .value == "STATE" :
1247+ interim_checkpoints .append ((message .state , cumulative_records .copy ()))
1248+
1249+ expected_deduped = list (
1250+ {orjson .dumps (r ): r for r in GLOBAL_CURSOR_RESUME_EXPECTED_RECORDS }.values ()
1251+ )
1252+
1253+ # Resume from every checkpoint except the final one (the final state is the
1254+ # completed sync, not an interruption).
1255+ for state , records_before_kill in interim_checkpoints [:- 1 ]:
1256+ resume_output = _run_read (
1257+ SUBSTREAM_MANIFEST_WITH_GLOBAL_CURSOR_AND_NO_DEPENDENCY ,
1258+ CONFIG ,
1259+ STREAM_NAME ,
1260+ [state ],
1261+ )
1262+ records_after_resume = [r .record .data for r in resume_output .records ]
1263+
1264+ combined = records_before_kill + records_after_resume
1265+ combined_deduped = list ({orjson .dumps (r ): r for r in combined }.values ())
1266+
1267+ assert sorted (combined_deduped , key = lambda x : x ["id" ]) == sorted (
1268+ expected_deduped , key = lambda x : x ["id" ]
1269+ ), (
1270+ "Records were skipped when resuming from interim checkpoint "
1271+ f"{ state .state .stream .stream_state .__dict__ } . "
1272+ f"Expected { expected_deduped } , got { combined_deduped } ."
1273+ )
1274+
1275+
10591276def run_incremental_parent_state_test (
10601277 manifest ,
10611278 mock_requests ,
0 commit comments