@@ -395,3 +395,164 @@ def test_resolvers_of_an_ended_sync_do_not_checkpoint_into_the_next_one():
395395 ]
396396 assert cursors
397397 assert all (cursor .startswith ("next-" ) for cursor in cursors )
398+
399+
400+ class HookCursorResolver (TableResolver ):
401+ """Checkpoints its cursor from a resource hook, before the resource is on the queue."""
402+
403+ def __init__ (self , state : StateClient , hook : str , parents : int ):
404+ super ().__init__ (
405+ Table ("parent" , [Column ("id" , pa .int64 ())]),
406+ child_resolvers = [ChildResolver (2 )],
407+ )
408+ self ._state = state
409+ self ._hook = hook
410+ self ._parents = parents
411+
412+ def resolve (self , client , parent_resource ) -> Generator [Any , None , None ]:
413+ for parent_id in range (self ._parents ):
414+ yield {"id" : parent_id }
415+
416+ def pre_resource_resolve (self , client , resource ):
417+ if self ._hook == "pre" :
418+ self ._checkpoint (resource )
419+
420+ def post_resource_resolve (self , client , resource ):
421+ if self ._hook == "post" :
422+ self ._checkpoint (resource )
423+
424+ def _checkpoint (self , resource ):
425+ self ._state .set_key ("hook-cursor" , str (resource .item ["id" ]))
426+ self ._state .flush ()
427+
428+
429+ @pytest .mark .parametrize ("hook" , ["pre" , "post" ])
430+ def test_checkpoints_flushed_by_resource_hooks_follow_the_resource_and_its_relations (
431+ hook ,
432+ ):
433+ parents = 10
434+ scheduler = Scheduler (4 )
435+ state = MemoryStateClient (checkpoints = scheduler .checkpoints )
436+ try :
437+ messages = list (
438+ scheduler .sync (TestClient (), [HookCursorResolver (state , hook , parents )])
439+ )
440+ finally :
441+ scheduler .shutdown ()
442+
443+ seen = set ()
444+ cursors = []
445+ for msg in messages :
446+ if isinstance (msg , SyncInsertMessage ):
447+ table = msg .record .schema .metadata [METADATA_TABLE_NAME ]
448+ row = msg .record .to_pylist ()[0 ]
449+ if table == b"parent" :
450+ seen .add ((row ["id" ],))
451+ else :
452+ seen .add ((row ["parent_id" ], row ["child_id" ]))
453+ elif isinstance (msg , SyncCheckpointMessage ):
454+ cursor = int (checkpoint_rows (msg )["hook-cursor" ])
455+ cursors .append (cursor )
456+ for parent_id in range (cursor + 1 ):
457+ assert (parent_id ,) in seen , f"cursor { cursor } sent before its parent"
458+ for child_id in range (2 ):
459+ assert (parent_id , child_id ) in seen , (
460+ f"cursor { cursor } sent before child { child_id } of parent "
461+ f"{ parent_id } "
462+ )
463+ assert cursors and cursors [- 1 ] == parents - 1
464+
465+
466+ class GrandchildResolver (TableResolver ):
467+ """Yields slowly, so it still runs after the child resolver that spawned it returned."""
468+
469+ def __init__ (self ):
470+ super ().__init__ (
471+ Table (
472+ "grandchild" ,
473+ [
474+ Column ("parent_id" , pa .int64 ()),
475+ Column ("child_id" , pa .int64 ()),
476+ Column ("grandchild_id" , pa .int64 ()),
477+ ],
478+ )
479+ )
480+
481+ def resolve (self , client , parent_resource ) -> Generator [Any , None , None ]:
482+ for grandchild_id in range (3 ):
483+ time .sleep (random .uniform (0.005 , 0.02 ))
484+ yield {** parent_resource .item , "grandchild_id" : grandchild_id }
485+
486+
487+ class FastChildResolver (TableResolver ):
488+ def __init__ (self ):
489+ super ().__init__ (
490+ Table (
491+ "fast_child" ,
492+ [Column ("parent_id" , pa .int64 ()), Column ("child_id" , pa .int64 ())],
493+ ),
494+ child_resolvers = [GrandchildResolver ()],
495+ )
496+
497+ def resolve (self , client , parent_resource ) -> Generator [Any , None , None ]:
498+ for child_id in range (2 ):
499+ yield {"parent_id" : parent_resource .item ["id" ], "child_id" : child_id }
500+
501+
502+ class GrandparentResolver (TableResolver ):
503+ def __init__ (self , state : StateClient , parents : int , flush_each : bool ):
504+ super ().__init__ (
505+ Table ("grandparent" , [Column ("id" , pa .int64 ())]),
506+ child_resolvers = [FastChildResolver ()],
507+ )
508+ self ._state = state
509+ self ._parents = parents
510+ self ._flush_each = flush_each
511+
512+ def resolve (self , client , parent_resource ) -> Generator [Any , None , None ]:
513+ for parent_id in range (self ._parents ):
514+ yield {"id" : parent_id }
515+ if self ._flush_each :
516+ self ._state .set_key ("grandparent-cursor" , str (parent_id ))
517+ self ._state .flush ()
518+ if not self ._flush_each :
519+ # the child resolvers return meanwhile, while their grandchildren still run
520+ time .sleep (0.01 )
521+ self ._state .set_key ("grandparent-cursor" , str (self ._parents - 1 ))
522+ self ._state .flush ()
523+
524+
525+ @pytest .mark .parametrize ("flush_each" , [True , False ])
526+ def test_parent_checkpoints_follow_the_relations_of_their_relations (flush_each ):
527+ parents = 10
528+ scheduler = Scheduler (8 )
529+ state = MemoryStateClient (checkpoints = scheduler .checkpoints )
530+ try :
531+ messages = list (
532+ scheduler .sync (
533+ TestClient (), [GrandparentResolver (state , parents , flush_each )]
534+ )
535+ )
536+ finally :
537+ scheduler .shutdown ()
538+
539+ seen = set ()
540+ cursors = []
541+ for msg in messages :
542+ if (
543+ isinstance (msg , SyncInsertMessage )
544+ and msg .record .schema .metadata [METADATA_TABLE_NAME ] == b"grandchild"
545+ ):
546+ row = msg .record .to_pylist ()[0 ]
547+ seen .add ((row ["parent_id" ], row ["child_id" ], row ["grandchild_id" ]))
548+ elif isinstance (msg , SyncCheckpointMessage ):
549+ cursor = int (checkpoint_rows (msg )["grandparent-cursor" ])
550+ cursors .append (cursor )
551+ for parent_id in range (cursor + 1 ):
552+ for child_id in range (2 ):
553+ for grandchild_id in range (3 ):
554+ assert (parent_id , child_id , grandchild_id ) in seen , (
555+ f"cursor { cursor } sent before grandchild "
556+ f"{ (parent_id , child_id , grandchild_id )} "
557+ )
558+ assert cursors and cursors [- 1 ] == parents - 1
0 commit comments