2929import static org .mockito .Mockito .when ;
3030
3131import com .google .cloud .Timestamp ;
32+ import com .google .cloud .spanner .ErrorCode ;
33+ import com .google .cloud .spanner .SpannerExceptionFactory ;
3234import com .google .cloud .spanner .Struct ;
3335import java .util .Arrays ;
3436import java .util .Optional ;
6264public class QueryChangeStreamActionTest {
6365 private static final String PARTITION_TOKEN = "partitionToken" ;
6466 private static final Timestamp PARTITION_START_TIMESTAMP = Timestamp .ofTimeMicroseconds (10L );
67+ private static final Timestamp RECORD_TIMESTAMP = Timestamp .ofTimeMicroseconds (20L );
6568 private static final Timestamp PARTITION_END_TIMESTAMP = Timestamp .ofTimeMicroseconds (30L );
6669 private static final long PARTITION_HEARTBEAT_MILLIS = 30_000L ;
6770 private static final Instant WATERMARK = Instant .now ();
@@ -139,6 +142,22 @@ public void setUp() throws Exception {
139142 when (partitionMetadataMapper .from (row )).thenReturn (partition );
140143 }
141144
145+ void setupUnboundedPartition () {
146+ partition =
147+ PartitionMetadata .newBuilder ()
148+ .setPartitionToken (PARTITION_TOKEN )
149+ .setParentTokens (Sets .newHashSet ("parentToken" ))
150+ .setStartTimestamp (PARTITION_START_TIMESTAMP )
151+ .setEndTimestamp (MAX_INCLUSIVE_END_AT )
152+ .setHeartbeatMillis (PARTITION_HEARTBEAT_MILLIS )
153+ .setState (SCHEDULED )
154+ .setWatermark (WATERMARK_TIMESTAMP )
155+ .setScheduledAt (Timestamp .now ())
156+ .build ();
157+ when (partitionMetadataMapper .from (any ())).thenReturn (partition );
158+ when (restriction .getTo ()).thenReturn (MAX_INCLUSIVE_END_AT );
159+ }
160+
142161 @ Test
143162 public void testQueryChangeStreamWithDataChangeRecord () {
144163 final Struct rowAsStruct = mock (Struct .class );
@@ -148,7 +167,7 @@ public void testQueryChangeStreamWithDataChangeRecord() {
148167 final DataChangeRecord record1 = mock (DataChangeRecord .class );
149168 final DataChangeRecord record2 = mock (DataChangeRecord .class );
150169 when (record1 .getRecordTimestamp ()).thenReturn (PARTITION_START_TIMESTAMP );
151- when (record2 .getRecordTimestamp ()).thenReturn (PARTITION_START_TIMESTAMP );
170+ when (record2 .getRecordTimestamp ()).thenReturn (RECORD_TIMESTAMP );
152171 when (changeStreamDao .changeStreamQuery (
153172 PARTITION_TOKEN ,
154173 PARTITION_START_TIMESTAMP ,
@@ -217,8 +236,8 @@ public void testQueryChangeStreamWithHeartbeatRecord() {
217236 final ChangeStreamResultSet resultSet = mock (ChangeStreamResultSet .class );
218237 final HeartbeatRecord record1 = mock (HeartbeatRecord .class );
219238 final HeartbeatRecord record2 = mock (HeartbeatRecord .class );
220- when (record1 .getRecordTimestamp ()).thenReturn (PARTITION_START_TIMESTAMP );
221- when (record2 .getRecordTimestamp ()).thenReturn (PARTITION_START_TIMESTAMP );
239+ when (record1 .getRecordTimestamp ()).thenReturn (RECORD_TIMESTAMP );
240+ when (record2 .getRecordTimestamp ()).thenReturn (RECORD_TIMESTAMP );
222241 when (changeStreamDao .changeStreamQuery (
223242 PARTITION_TOKEN ,
224243 PARTITION_START_TIMESTAMP ,
@@ -501,19 +520,19 @@ public void testQueryChangeStreamWithRestrictionFromAfterPartitionStartForPartit
501520 }
502521
503522 @ Test
504- public void testQueryChangeStreamWithPartitionEndRecord () {
523+ public void testQueryChangeStreamWithPartitionEndRecordBoundedRestriction () {
505524 final ChangeStreamResultSetMetadata resultSetMetadata =
506525 mock (ChangeStreamResultSetMetadata .class );
507526 final ChangeStreamResultSet resultSet = mock (ChangeStreamResultSet .class );
508527 final PartitionEndRecord record1 = mock (PartitionEndRecord .class );
509- when (record1 .getRecordTimestamp ()).thenReturn (PARTITION_END_TIMESTAMP );
528+ when (record1 .getRecordTimestamp ()).thenReturn (RECORD_TIMESTAMP );
510529 when (changeStreamDao .changeStreamQuery (
511530 PARTITION_TOKEN ,
512531 PARTITION_START_TIMESTAMP ,
513532 PARTITION_END_TIMESTAMP ,
514533 PARTITION_HEARTBEAT_MILLIS ))
515534 .thenReturn (resultSet );
516- when (resultSet .next ()).thenReturn (true );
535+ when (resultSet .next ()).thenReturn (true , false );
517536 when (resultSet .getMetadata ()).thenReturn (resultSetMetadata );
518537 when (changeStreamRecordMapper .toChangeStreamRecords (partition , resultSet , resultSetMetadata ))
519538 .thenReturn (Arrays .asList (record1 ));
@@ -523,8 +542,9 @@ public void testQueryChangeStreamWithPartitionEndRecord() {
523542 eq (restrictionTracker ),
524543 any (RestrictionInterrupter .class ),
525544 eq (watermarkEstimator )))
526- .thenReturn (Optional .of ( ProcessContinuation . stop () ));
545+ .thenReturn (Optional .empty ( ));
527546 when (watermarkEstimator .currentWatermark ()).thenReturn (WATERMARK );
547+ when (restrictionTracker .tryClaim (any (Timestamp .class ))).thenReturn (true );
528548
529549 final ProcessContinuation result =
530550 action .run (
@@ -539,13 +559,66 @@ public void testQueryChangeStreamWithPartitionEndRecord() {
539559 any (RestrictionInterrupter .class ),
540560 eq (watermarkEstimator ));
541561 verify (partitionMetadataDao ).updateWatermark (PARTITION_TOKEN , WATERMARK_TIMESTAMP );
562+ verify (restrictionTracker ).tryClaim (PARTITION_END_TIMESTAMP );
563+
564+ verify (dataChangeRecordAction , never ()).run (any (), any (), any (), any (), any (), any ());
565+ verify (heartbeatRecordAction , never ()).run (any (), any (), any (), any (), any ());
566+ verify (childPartitionsRecordAction , never ()).run (any (), any (), any (), any (), any ());
567+ verify (partitionStartRecordAction , never ()).run (any (), any (), any (), any (), any ());
568+ verify (partitionEventRecordAction , never ()).run (any (), any (), any (), any (), any ());
569+ }
570+
571+ @ Test
572+ public void testQueryChangeStreamWithPartitionEndRecordUnboundedRestriction () {
573+ setupUnboundedPartition ();
574+
575+ final ChangeStreamResultSetMetadata resultSetMetadata =
576+ mock (ChangeStreamResultSetMetadata .class );
577+ final ChangeStreamResultSet resultSet = mock (ChangeStreamResultSet .class );
578+ final PartitionEndRecord record1 = mock (PartitionEndRecord .class );
579+ when (record1 .getRecordTimestamp ()).thenReturn (RECORD_TIMESTAMP );
580+ final ArgumentCaptor <Timestamp > timestampCaptor = ArgumentCaptor .forClass (Timestamp .class );
581+ when (changeStreamDao .changeStreamQuery (
582+ eq (PARTITION_TOKEN ),
583+ eq (PARTITION_START_TIMESTAMP ),
584+ timestampCaptor .capture (),
585+ eq (PARTITION_HEARTBEAT_MILLIS )))
586+ .thenReturn (resultSet );
587+ when (resultSet .next ()).thenReturn (true , false );
588+ when (resultSet .getMetadata ()).thenReturn (resultSetMetadata );
589+ when (changeStreamRecordMapper .toChangeStreamRecords (partition , resultSet , resultSetMetadata ))
590+ .thenReturn (Arrays .asList (record1 ));
591+ when (partitionEndRecordAction .run (
592+ eq (partition ),
593+ eq (record1 ),
594+ eq (restrictionTracker ),
595+ any (RestrictionInterrupter .class ),
596+ eq (watermarkEstimator )))
597+ .thenReturn (Optional .empty ());
598+ when (watermarkEstimator .currentWatermark ()).thenReturn (WATERMARK );
599+ when (restrictionTracker .tryClaim (any (Timestamp .class ))).thenReturn (true );
600+
601+ final ProcessContinuation result =
602+ action .run (
603+ partition , restrictionTracker , outputReceiver , watermarkEstimator , bundleFinalizer );
604+
605+ assertEquals (ProcessContinuation .stop (), result );
606+ assertNotEquals (MAX_INCLUSIVE_END_AT , timestampCaptor .getValue ());
607+ verify (partitionEndRecordAction )
608+ .run (
609+ eq (partition ),
610+ eq (record1 ),
611+ eq (restrictionTracker ),
612+ any (RestrictionInterrupter .class ),
613+ eq (watermarkEstimator ));
614+ verify (partitionMetadataDao ).updateWatermark (PARTITION_TOKEN , WATERMARK_TIMESTAMP );
615+ verify (restrictionTracker ).tryClaim (MAX_INCLUSIVE_END_AT );
542616
543617 verify (dataChangeRecordAction , never ()).run (any (), any (), any (), any (), any (), any ());
544618 verify (heartbeatRecordAction , never ()).run (any (), any (), any (), any (), any ());
545619 verify (childPartitionsRecordAction , never ()).run (any (), any (), any (), any (), any ());
546620 verify (partitionStartRecordAction , never ()).run (any (), any (), any (), any (), any ());
547621 verify (partitionEventRecordAction , never ()).run (any (), any (), any (), any (), any ());
548- verify (restrictionTracker , never ()).tryClaim (any ());
549622 }
550623
551624 @ Test
@@ -614,7 +687,6 @@ public void testQueryChangeStreamWithStreamFinished() {
614687 partition , restrictionTracker , outputReceiver , watermarkEstimator , bundleFinalizer );
615688
616689 assertEquals (ProcessContinuation .stop (), result );
617- verify (partitionMetadataDao ).updateWatermark (PARTITION_TOKEN , WATERMARK_TIMESTAMP );
618690 verify (partitionMetadataDao ).updateToFinished (PARTITION_TOKEN );
619691 verify (metrics ).decActivePartitionReadCounter ();
620692
@@ -672,6 +744,76 @@ public void testQueryChangeStreamFinishedWithResume() {
672744 verify (partitionEventRecordAction , never ()).run (any (), any (), any (), any (), any ());
673745 }
674746
747+ // Out of range indicates that we're beyond the end of the partition and should stop
748+ // processing.
749+ @ Test
750+ public void testQueryChangeStreamWithOutOfRangeErrorOnUnboundedPartition () {
751+ setupUnboundedPartition ();
752+
753+ final ArgumentCaptor <Timestamp > timestampCaptor = ArgumentCaptor .forClass (Timestamp .class );
754+ when (changeStreamDao .changeStreamQuery (
755+ eq (PARTITION_TOKEN ),
756+ eq (PARTITION_START_TIMESTAMP ),
757+ timestampCaptor .capture (),
758+ eq (PARTITION_HEARTBEAT_MILLIS )))
759+ .thenThrow (
760+ SpannerExceptionFactory .newSpannerException (
761+ ErrorCode .OUT_OF_RANGE , "Specified start_timestamp is invalid" ));
762+ when (watermarkEstimator .currentWatermark ()).thenReturn (WATERMARK );
763+ when (restrictionTracker .tryClaim (any (Timestamp .class ))).thenReturn (true );
764+
765+ final ProcessContinuation result =
766+ action .run (
767+ partition , restrictionTracker , outputReceiver , watermarkEstimator , bundleFinalizer );
768+ assertEquals (ProcessContinuation .stop (), result );
769+ assertNotEquals (MAX_INCLUSIVE_END_AT , timestampCaptor .getValue ());
770+
771+ verify (restrictionTracker ).tryClaim (MAX_INCLUSIVE_END_AT );
772+ verify (partitionMetadataDao ).updateToFinished (PARTITION_TOKEN );
773+ verify (metrics ).decActivePartitionReadCounter ();
774+ verify (partitionMetadataDao ).updateWatermark (PARTITION_TOKEN , WATERMARK_TIMESTAMP );
775+
776+ verify (dataChangeRecordAction , never ()).run (any (), any (), any (), any (), any (), any ());
777+ verify (heartbeatRecordAction , never ()).run (any (), any (), any (), any (), any ());
778+ verify (childPartitionsRecordAction , never ()).run (any (), any (), any (), any (), any ());
779+ verify (partitionStartRecordAction , never ()).run (any (), any (), any (), any (), any ());
780+ verify (partitionEndRecordAction , never ()).run (any (), any (), any (), any (), any ());
781+ verify (partitionEventRecordAction , never ()).run (any (), any (), any (), any (), any ());
782+ }
783+
784+ // Out of range indicates that we're beyond the end of the partition and should stop
785+ // processing.
786+ @ Test
787+ public void testQueryChangeStreamWithOutOfRangeErrorOnBoundedPartition () {
788+ when (changeStreamDao .changeStreamQuery (
789+ eq (PARTITION_TOKEN ),
790+ eq (PARTITION_START_TIMESTAMP ),
791+ eq (PARTITION_END_TIMESTAMP ),
792+ eq (PARTITION_HEARTBEAT_MILLIS )))
793+ .thenThrow (
794+ SpannerExceptionFactory .newSpannerException (
795+ ErrorCode .OUT_OF_RANGE , "Specified start_timestamp is invalid" ));
796+ when (watermarkEstimator .currentWatermark ()).thenReturn (WATERMARK );
797+ when (restrictionTracker .tryClaim (any (Timestamp .class ))).thenReturn (true );
798+
799+ final ProcessContinuation result =
800+ action .run (
801+ partition , restrictionTracker , outputReceiver , watermarkEstimator , bundleFinalizer );
802+ assertEquals (ProcessContinuation .stop (), result );
803+
804+ verify (restrictionTracker ).tryClaim (PARTITION_END_TIMESTAMP );
805+ verify (partitionMetadataDao ).updateToFinished (PARTITION_TOKEN );
806+ verify (metrics ).decActivePartitionReadCounter ();
807+ verify (partitionMetadataDao ).updateWatermark (PARTITION_TOKEN , WATERMARK_TIMESTAMP );
808+
809+ verify (dataChangeRecordAction , never ()).run (any (), any (), any (), any (), any (), any ());
810+ verify (heartbeatRecordAction , never ()).run (any (), any (), any (), any (), any ());
811+ verify (childPartitionsRecordAction , never ()).run (any (), any (), any (), any (), any ());
812+ verify (partitionStartRecordAction , never ()).run (any (), any (), any (), any (), any ());
813+ verify (partitionEndRecordAction , never ()).run (any (), any (), any (), any (), any ());
814+ verify (partitionEventRecordAction , never ()).run (any (), any (), any (), any (), any ());
815+ }
816+
675817 private static class BundleFinalizerStub implements BundleFinalizer {
676818 @ Override
677819 public void afterBundleCommit (Instant callbackExpiry , Callback callback ) {
0 commit comments