Skip to content

Commit cf52551

Browse files
devin-ai-integration[bot]bot_apk
andcommitted
test(cdk): resume global-cursor substream after mid-walk interruption without skipping records
Co-Authored-By: bot_apk <apk@cognition.ai>
1 parent 0e2c06c commit cf52551

1 file changed

Lines changed: 217 additions & 0 deletions

File tree

unit_tests/sources/declarative/incremental/test_concurrent_perpartitioncursor.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
10591276
def run_incremental_parent_state_test(
10601277
manifest,
10611278
mock_requests,

0 commit comments

Comments
 (0)