Skip to content

Commit d66b623

Browse files
devin-ai-integration[bot]bot_apk
andcommitted
test(concurrent-cursor): harden global-cursor resume test (OC-12977)
Adds four hardening checks around the empty-parent guard removal: - no-op test for the non-empty-parent-state (incremental_dependency) shape: records + STATE byte-identical guard-removed vs guard-restored, bounding the shared-CDK change's blast radius to the empty-parent case; - data-last fixture: many empty parents precede the single data-bearing partition, so resume is exercised from checkpoints emitted during the silent stretch before any record exists; - determinism: pin default_concurrency=1 and assert RECORD/STATE interleaving is identical across repeated runs (no concurrent-ordering flake); - assert resume meaningfully contributes the tail (records not already emitted before the checkpoint), so the test can't pass trivially. Co-Authored-By: bot_apk <apk@cognition.ai>
1 parent d54aa34 commit d66b623

1 file changed

Lines changed: 238 additions & 30 deletions

File tree

unit_tests/sources/declarative/incremental/test_concurrent_perpartitioncursor.py

Lines changed: 238 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,29 @@
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

329352
import orjson
330353
import 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

Comments
 (0)