325325 "global_substream_cursor"
326326] = True
327327
328+ # Single-worker (serial) variants used by the resume/no-op tests below. Pinning
329+ # `default_concurrency` to 1 makes the RECORD<->STATE interleaving deterministic so the resume
330+ # tests can reconstruct "records committed before the kill" without becoming a concurrent-ordering
331+ # flake. It is hardcoded rather than driven by `config['num_workers']` because the test spec sets
332+ # `additionalProperties: False` and rejects an extra `num_workers` config field.
333+ SUBSTREAM_MANIFEST_GLOBAL_CURSOR_NO_DEPENDENCY_SERIAL = deepcopy (
334+ SUBSTREAM_MANIFEST_WITH_GLOBAL_CURSOR_AND_NO_DEPENDENCY
335+ )
336+ SUBSTREAM_MANIFEST_GLOBAL_CURSOR_NO_DEPENDENCY_SERIAL ["concurrency_level" ][
337+ "default_concurrency"
338+ ] = 1
339+
340+ # Same global cursor, but the parent keeps `incremental_dependency: true` (the
341+ # `customer_balance_transactions` shape) so `_parent_state` is non-empty during the walk and the
342+ # empty-parent guard never fired -- used to prove the guard removal is a no-op there.
343+ SUBSTREAM_MANIFEST_GLOBAL_CURSOR_WITH_DEPENDENCY_SERIAL = deepcopy (SUBSTREAM_MANIFEST )
344+ SUBSTREAM_MANIFEST_GLOBAL_CURSOR_WITH_DEPENDENCY_SERIAL ["definitions" ]["cursor_incremental_sync" ][
345+ "global_substream_cursor"
346+ ] = True
347+ SUBSTREAM_MANIFEST_GLOBAL_CURSOR_WITH_DEPENDENCY_SERIAL ["concurrency_level" ][
348+ "default_concurrency"
349+ ] = 1
350+
328351
329352import orjson
330353import requests_mock
@@ -1195,40 +1218,131 @@ def test_incremental_parent_state_no_incremental_dependency(
11951218]
11961219
11971220
1198- def test_global_cursor_substream_resume_after_interruption_skips_no_records ():
1199- """Interrupt a global-cursor substream mid-walk, resume from every interim checkpoint, and prove no records are skipped.
1221+ # Data-last variant of the resume fixture that mirrors the real OC-12977 failure more faithfully:
1222+ # a run of empty parents (comments with no votes) *precedes* the single data-bearing parent
1223+ # (comment 105). Interim checkpoint STATE is therefore emitted repeatedly during the empty stretch,
1224+ # before any record exists -- exactly the checkpoints we most need to prove are safe to resume from.
1225+ GLOBAL_CURSOR_DATA_LAST_MOCK_REQUESTS = [
1226+ (
1227+ f"https://api.example.com/community/posts?per_page=100&start_time={ START_DATE } " ,
1228+ {"posts" : [{"id" : 1 , "updated_at" : POST_1_UPDATED_AT }]},
1229+ ),
1230+ (
1231+ "https://api.example.com/community/posts/1/comments?per_page=100" ,
1232+ {
1233+ "comments" : [
1234+ {"id" : 101 , "post_id" : 1 , "updated_at" : "2024-01-20T00:00:00Z" },
1235+ {"id" : 102 , "post_id" : 1 , "updated_at" : "2024-01-19T00:00:00Z" },
1236+ {"id" : 103 , "post_id" : 1 , "updated_at" : "2024-01-18T00:00:00Z" },
1237+ {"id" : 104 , "post_id" : 1 , "updated_at" : "2024-01-17T00:00:00Z" },
1238+ {"id" : 105 , "post_id" : 1 , "updated_at" : "2024-01-25T00:00:00Z" },
1239+ ]
1240+ },
1241+ ),
1242+ # The first four parents are empty (the long silent stretch).
1243+ (
1244+ f"https://api.example.com/community/posts/1/comments/101/votes?per_page=100&start_time={ START_DATE } " ,
1245+ {"votes" : []},
1246+ ),
1247+ (
1248+ f"https://api.example.com/community/posts/1/comments/102/votes?per_page=100&start_time={ START_DATE } " ,
1249+ {"votes" : []},
1250+ ),
1251+ (
1252+ f"https://api.example.com/community/posts/1/comments/103/votes?per_page=100&start_time={ START_DATE } " ,
1253+ {"votes" : []},
1254+ ),
1255+ (
1256+ f"https://api.example.com/community/posts/1/comments/104/votes?per_page=100&start_time={ START_DATE } " ,
1257+ {"votes" : []},
1258+ ),
1259+ # The data-bearing parent comes LAST.
1260+ (
1261+ f"https://api.example.com/community/posts/1/comments/105/votes?per_page=100&start_time={ START_DATE } " ,
1262+ {
1263+ "votes" : [
1264+ {"id" : 500 , "comment_id" : 105 , "created_at" : "2024-01-15T00:00:00Z" },
1265+ {"id" : 501 , "comment_id" : 105 , "created_at" : "2024-01-16T00:00:00Z" },
1266+ ]
1267+ },
1268+ ),
1269+ ]
12001270
1201- Regression test for OC-12977. A ``global_substream_cursor`` substream whose parent has no
1202- ``incremental_dependency`` (empty parent state -- the ``bank_accounts`` shape) now emits its
1203- throttled checkpoint STATE during the walk because the empty-parent guard was removed. This
1204- test proves those interim checkpoints are *safe to resume from*: for each interim STATE the
1205- cursor emits, we simulate the source being killed right after it (keeping only the records
1206- emitted before that STATE), then start a fresh read seeded with that STATE and assert the
1207- union of pre-kill records and resume records covers the full expected set with nothing
1208- skipped. Because the interim global cursor is inert until the sync finishes, resuming
1209- re-reads rather than skips -- which is exactly the no-data-loss guarantee we want.
1271+ GLOBAL_CURSOR_DATA_LAST_EXPECTED_RECORDS = [
1272+ {
1273+ "comment_id" : 105 ,
1274+ "comment_updated_at" : "2024-01-25T00:00:00Z" ,
1275+ "created_at" : "2024-01-15T00:00:00Z" ,
1276+ "id" : 500 ,
1277+ },
1278+ {
1279+ "comment_id" : 105 ,
1280+ "comment_updated_at" : "2024-01-25T00:00:00Z" ,
1281+ "created_at" : "2024-01-16T00:00:00Z" ,
1282+ "id" : 501 ,
1283+ },
1284+ ]
1285+
1286+
1287+ def _record_state_sequence (output : EntrypointOutput ) -> List [tuple ]:
1288+ """Return the ordered `(kind, record-id)` sequence of RECORD/STATE messages.
1289+
1290+ Used to assert the RECORD<->STATE interleaving is deterministic across repeated runs so the
1291+ resume reconstruction below cannot become a concurrent-ordering flake.
1292+ """
1293+ sequence : List [tuple ] = []
1294+ for message in output .records_and_state_messages :
1295+ if message .type .value == "RECORD" :
1296+ sequence .append (("RECORD" , message .record .data ["id" ]))
1297+ elif message .type .value == "STATE" :
1298+ sequence .append (("STATE" , None ))
1299+ return sequence
1300+
1301+
1302+ def _assert_resume_from_every_interim_checkpoint_skips_no_records (
1303+ manifest : Mapping [str , Any ],
1304+ mock_requests : List [tuple ],
1305+ expected_records : List [Mapping [str , Any ]],
1306+ ) -> None :
1307+ """Prove every interim checkpoint of a global-cursor substream is safe to resume from.
1308+
1309+ Runs `manifest` to completion with the 600s throttle disabled (so an interim checkpoint STATE
1310+ is emitted at every `close_partition`), then for each interim STATE simulates a kill right after
1311+ it -- keeping only the records emitted before that STATE -- and a fresh resume seeded with it.
1312+ Asserts:
1313+
1314+ 1. the RECORD<->STATE interleaving is deterministic across repeated runs (the caller pins
1315+ `default_concurrency` to 1, so this cannot become a concurrent-ordering flake);
1316+ 2. the union of pre-kill and resumed records equals the full expected set with nothing skipped;
1317+ and
1318+ 3. resume meaningfully contributes the tail -- for at least one checkpoint that precedes some
1319+ data, the resumed read returns records not already emitted before the kill, so the test
1320+ cannot pass trivially if all records preceded the first checkpoint.
12101321 """
1211- # Disable the 600s throttle so an interim checkpoint STATE is emitted at every close_partition,
1212- # giving us many mid-walk "kill" points to resume from.
12131322 with patch .object (
12141323 ConcurrentPerPartitionCursor , "_throttle_state_message" , return_value = 9999999.0
12151324 ):
12161325 with requests_mock .Mocker () as m :
1217- for url , response in GLOBAL_CURSOR_RESUME_MOCK_REQUESTS :
1326+ for url , response in mock_requests :
12181327 m .get (url , json = response )
12191328
12201329 # Fresh initial sync (no incoming state), interrupted implicitly by inspecting
12211330 # every interim checkpoint it emits.
1222- output = _run_read (
1223- SUBSTREAM_MANIFEST_WITH_GLOBAL_CURSOR_AND_NO_DEPENDENCY ,
1224- CONFIG ,
1225- STREAM_NAME ,
1226- None ,
1331+ output = _run_read (manifest , CONFIG , STREAM_NAME , None )
1332+
1333+ # (1) Determinism: the same fixture must produce byte-identical record/state
1334+ # interleaving on every run, otherwise reconstructing "records committed before the
1335+ # kill" would be flaky under the concurrent engine.
1336+ rerun = _run_read (manifest , CONFIG , STREAM_NAME , None )
1337+ assert _record_state_sequence (output ) == _record_state_sequence (rerun ), (
1338+ "RECORD/STATE interleaving is not deterministic across runs; the resume test would "
1339+ "be a concurrent-ordering flake."
12271340 )
1341+ assert [r .record .data for r in output .records ] == [r .record .data for r in rerun .records ]
12281342
12291343 # Sanity: the uninterrupted run yields the full expected record set.
12301344 assert sorted ([r .record .data for r in output .records ], key = lambda x : x ["id" ]) == sorted (
1231- GLOBAL_CURSOR_RESUME_EXPECTED_RECORDS , key = lambda x : x ["id" ]
1345+ expected_records , key = lambda x : x ["id" ]
12321346 )
12331347
12341348 # The fix must produce at least one interim checkpoint STATE during the walk
@@ -1240,27 +1354,21 @@ def test_global_cursor_substream_resume_after_interruption_skips_no_records():
12401354
12411355 # Map each interim STATE to the records emitted before it (what the destination
12421356 # would have committed had the source been killed right after that checkpoint).
1243- cumulative_records = []
1357+ cumulative_records : List [ Mapping [ str , Any ]] = []
12441358 interim_checkpoints = []
12451359 for message in output .records_and_state_messages :
12461360 if message .type .value == "RECORD" :
12471361 cumulative_records .append (message .record .data )
12481362 elif message .type .value == "STATE" :
12491363 interim_checkpoints .append ((message .state , cumulative_records .copy ()))
12501364
1251- expected_deduped = list (
1252- {orjson .dumps (r ): r for r in GLOBAL_CURSOR_RESUME_EXPECTED_RECORDS }.values ()
1253- )
1365+ expected_deduped = list ({orjson .dumps (r ): r for r in expected_records }.values ())
12541366
12551367 # Resume from every checkpoint except the final one (the final state is the
12561368 # completed sync, not an interruption).
1369+ resume_contributed_new_tail = False
12571370 for state , records_before_kill in interim_checkpoints [:- 1 ]:
1258- resume_output = _run_read (
1259- SUBSTREAM_MANIFEST_WITH_GLOBAL_CURSOR_AND_NO_DEPENDENCY ,
1260- CONFIG ,
1261- STREAM_NAME ,
1262- [state ],
1263- )
1371+ resume_output = _run_read (manifest , CONFIG , STREAM_NAME , [state ])
12641372 records_after_resume = [r .record .data for r in resume_output .records ]
12651373
12661374 combined = records_before_kill + records_after_resume
@@ -1274,6 +1382,106 @@ def test_global_cursor_substream_resume_after_interruption_skips_no_records():
12741382 f"Expected { expected_deduped } , got { combined_deduped } ."
12751383 )
12761384
1385+ # (3) The resume must actually supply records that were not already committed
1386+ # before the kill, for at least one checkpoint that precedes some data.
1387+ before_keys = {orjson .dumps (r ) for r in records_before_kill }
1388+ new_from_resume = [
1389+ r for r in records_after_resume if orjson .dumps (r ) not in before_keys
1390+ ]
1391+ if (
1392+ len (records_before_kill ) < len (expected_deduped )
1393+ and records_after_resume
1394+ and new_from_resume
1395+ ):
1396+ resume_contributed_new_tail = True
1397+
1398+ assert resume_contributed_new_tail , (
1399+ "No interim checkpoint's resume contributed records beyond those already emitted "
1400+ "before the kill; the test could pass trivially if all records preceded the first "
1401+ "checkpoint."
1402+ )
1403+
1404+
1405+ def test_global_cursor_substream_resume_after_interruption_skips_no_records ():
1406+ """Interrupt a global-cursor substream mid-walk, resume from every interim checkpoint, and prove no records are skipped.
1407+
1408+ Regression test for OC-12977. A ``global_substream_cursor`` substream whose parent has no
1409+ ``incremental_dependency`` (empty parent state -- the ``bank_accounts`` shape) now emits its
1410+ throttled checkpoint STATE during the walk because the empty-parent guard was removed. This
1411+ test proves those interim checkpoints are *safe to resume from*: for each interim STATE the
1412+ cursor emits, we simulate the source being killed right after it (keeping only the records
1413+ emitted before that STATE), then start a fresh read seeded with that STATE and assert the
1414+ union of pre-kill records and resume records covers the full expected set with nothing
1415+ skipped. Because the interim global cursor is inert until the sync finishes, resuming
1416+ re-reads rather than skips -- which is exactly the no-data-loss guarantee we want.
1417+ """
1418+ _assert_resume_from_every_interim_checkpoint_skips_no_records (
1419+ SUBSTREAM_MANIFEST_GLOBAL_CURSOR_NO_DEPENDENCY_SERIAL ,
1420+ GLOBAL_CURSOR_RESUME_MOCK_REQUESTS ,
1421+ GLOBAL_CURSOR_RESUME_EXPECTED_RECORDS ,
1422+ )
1423+
1424+
1425+ def test_global_cursor_substream_resume_with_data_bearing_partition_last_skips_no_records ():
1426+ """Resume is safe even when the data-bearing partition comes after a long empty-parent stretch.
1427+
1428+ This mirrors the customer failure more faithfully than the front-loaded fixture: the source
1429+ emits several interim checkpoint STATE messages while walking empty parents *before* any record
1430+ exists, then the last parent finally yields the data. Resuming from any of those early
1431+ (zero-record) checkpoints must still recover the full tail with nothing skipped -- proving the
1432+ dangerous case (checkpoints emitted during the silent stretch that precedes real data) is safe.
1433+ """
1434+ _assert_resume_from_every_interim_checkpoint_skips_no_records (
1435+ SUBSTREAM_MANIFEST_GLOBAL_CURSOR_NO_DEPENDENCY_SERIAL ,
1436+ GLOBAL_CURSOR_DATA_LAST_MOCK_REQUESTS ,
1437+ GLOBAL_CURSOR_DATA_LAST_EXPECTED_RECORDS ,
1438+ )
1439+
1440+
1441+ def test_guard_removal_is_a_noop_for_non_empty_parent_state ():
1442+ """The guard removal is a complete no-op for the non-empty-parent-state (incremental_dependency) shape.
1443+
1444+ The empty-parent guard only ever fired when ``_use_global_cursor and not _parent_state``. For a
1445+ substream whose parent has ``incremental_dependency: true`` (the ``customer_balance_transactions``
1446+ shape), ``_parent_state`` is non-empty during the walk, so the guard was already bypassed. This
1447+ test proves removing it changes nothing there: on the same fixture, the emitted records and the
1448+ full sequence of STATE messages are byte-for-byte identical with the guard removed (current
1449+ code) vs. restored (pre-fix). This bounds the blast radius of the shared-CDK change to the
1450+ empty-parent case, which is the main concern for a change in shared CDK code.
1451+ """
1452+ with patch .object (
1453+ ConcurrentPerPartitionCursor , "_throttle_state_message" , return_value = 9999999.0
1454+ ):
1455+ with requests_mock .Mocker () as m :
1456+ for url , response in GLOBAL_CURSOR_RESUME_MOCK_REQUESTS :
1457+ m .get (url , json = response )
1458+
1459+ after_output = _run_read (
1460+ SUBSTREAM_MANIFEST_GLOBAL_CURSOR_WITH_DEPENDENCY_SERIAL , CONFIG , STREAM_NAME , None
1461+ )
1462+ with patch .object (
1463+ ConcurrentPerPartitionCursor ,
1464+ "_emit_state_message" ,
1465+ _emit_state_message_with_guard ,
1466+ ):
1467+ before_output = _run_read (
1468+ SUBSTREAM_MANIFEST_GLOBAL_CURSOR_WITH_DEPENDENCY_SERIAL ,
1469+ CONFIG ,
1470+ STREAM_NAME ,
1471+ None ,
1472+ )
1473+
1474+ assert [r .record .data for r in after_output .records ] == [
1475+ r .record .data for r in before_output .records
1476+ ], "Guard removal changed emitted records for the non-empty-parent-state shape."
1477+
1478+ after_states = [m .state .stream .stream_state .__dict__ for m in after_output .state_messages ]
1479+ before_states = [m .state .stream .stream_state .__dict__ for m in before_output .state_messages ]
1480+ assert after_states == before_states , (
1481+ "Guard removal changed emitted STATE for the non-empty-parent-state shape; the change must "
1482+ "be a no-op when the parent has incremental_dependency (non-empty parent state)."
1483+ )
1484+
12771485
12781486# OC-12977 / Serhii's orchestrator-overload risk: removing the empty-parent guard makes a
12791487# global_substream_cursor substream emit interim checkpoint STATE during a long empty-parent
0 commit comments