airbyte_cdk.sources.streams.concurrent.cursor
1# 2# Copyright (c) 2023 Airbyte, Inc., all rights reserved. 3# 4 5import datetime 6import functools 7import logging 8import threading 9from abc import ABC, abstractmethod 10from typing import ( 11 Any, 12 Callable, 13 Iterable, 14 List, 15 Mapping, 16 MutableMapping, 17 Optional, 18 Tuple, 19 Union, 20) 21 22from airbyte_cdk.models import FailureType 23from airbyte_cdk.sources.connector_state_manager import ConnectorStateManager 24from airbyte_cdk.sources.message import MessageRepository, NoopMessageRepository 25from airbyte_cdk.sources.streams import NO_CURSOR_STATE_KEY 26from airbyte_cdk.sources.streams.concurrent.clamping import ClampingStrategy, NoClamping 27from airbyte_cdk.sources.streams.concurrent.cursor_types import CursorValueType, GapType 28from airbyte_cdk.sources.streams.concurrent.partitions.partition import Partition 29from airbyte_cdk.sources.streams.concurrent.partitions.stream_slicer import StreamSlicer 30from airbyte_cdk.sources.streams.concurrent.state_converters.abstract_stream_state_converter import ( 31 AbstractStreamStateConverter, 32) 33from airbyte_cdk.sources.types import Record, StreamSlice 34from airbyte_cdk.utils.traced_exception import AirbyteTracedException 35 36LOGGER = logging.getLogger("airbyte") 37 38 39def _extract_value(mapping: Mapping[str, Any], path: List[str]) -> Any: 40 return functools.reduce(lambda a, b: a[b], path, mapping) 41 42 43class CursorField: 44 def __init__( 45 self, cursor_field_key: str, supports_catalog_defined_cursor_field: bool = False 46 ) -> None: 47 self.cursor_field_key = cursor_field_key 48 self.supports_catalog_defined_cursor_field = supports_catalog_defined_cursor_field 49 50 def extract_value(self, record: Record) -> Any: 51 cursor_value = record.data.get(self.cursor_field_key) 52 if cursor_value is None: 53 raise ValueError(f"Could not find cursor field {self.cursor_field_key} in record") 54 return cursor_value # type: ignore # we assume that the value the path points at is a comparable 55 56 57class Cursor(StreamSlicer, ABC): 58 @property 59 @abstractmethod 60 def state(self) -> MutableMapping[str, Any]: ... 61 62 @abstractmethod 63 def observe(self, record: Record) -> None: 64 """ 65 Indicate to the cursor that the record has been emitted 66 """ 67 raise NotImplementedError() 68 69 @abstractmethod 70 def close_partition(self, partition: Partition) -> None: 71 """ 72 Indicate to the cursor that the partition has been successfully processed 73 """ 74 raise NotImplementedError() 75 76 @abstractmethod 77 def ensure_at_least_one_state_emitted(self) -> None: 78 """ 79 State messages are emitted when a partition is closed. However, the platform expects at least one state to be emitted per sync per 80 stream. Hence, if no partitions are generated, this method needs to be called. 81 """ 82 raise NotImplementedError() 83 84 @abstractmethod 85 def should_be_synced(self, record: Record) -> bool: 86 pass 87 88 def stream_slices(self) -> Iterable[StreamSlice]: 89 """ 90 Default placeholder implementation of generate_slices. 91 Subclasses can override this method to provide actual behavior. 92 """ 93 yield StreamSlice(partition={}, cursor_slice={}) 94 95 def get_cursor_datetime_from_state( 96 self, stream_state: Mapping[str, Any] 97 ) -> datetime.datetime | None: 98 """Extract and parse the cursor datetime from the given stream state. 99 100 This method is used by StateDelegatingStream to validate cursor age against 101 an API's data retention period. Subclasses should implement this method to 102 extract the cursor value from their specific state structure and parse it 103 into a datetime object. 104 105 Returns None if the cursor cannot be extracted or parsed, which will cause 106 StateDelegatingStream to fall back to full refresh (safe default). 107 108 Raises NotImplementedError by default - subclasses must implement this method 109 if they want to support cursor age validation with api_retention_period. 110 """ 111 raise NotImplementedError( 112 f"{self.__class__.__name__} does not implement get_cursor_datetime_from_state. " 113 f"Cursor age validation with api_retention_period is not supported for this cursor type." 114 ) 115 116 117class FinalStateCursor(Cursor): 118 """Cursor that is used to guarantee at least one state message is emitted for a concurrent stream.""" 119 120 def __init__( 121 self, 122 stream_name: str, 123 stream_namespace: Optional[str], 124 message_repository: MessageRepository, 125 ) -> None: 126 self._stream_name = stream_name 127 self._stream_namespace = stream_namespace 128 self._message_repository = message_repository 129 # Normally the connector state manager operates at the source-level. However, we only need it to write the sentinel 130 # state message rather than manage overall source state. This is also only temporary as we move to the resumable 131 # full refresh world where every stream uses a FileBasedConcurrentCursor with incremental state. 132 self._connector_state_manager = ConnectorStateManager() 133 self._has_closed_at_least_one_slice = False 134 135 @property 136 def state(self) -> MutableMapping[str, Any]: 137 return {NO_CURSOR_STATE_KEY: True} 138 139 def observe(self, record: Record) -> None: 140 pass 141 142 def close_partition(self, partition: Partition) -> None: 143 pass 144 145 def ensure_at_least_one_state_emitted(self) -> None: 146 """ 147 Used primarily for full refresh syncs that do not have a valid cursor value to emit at the end of a sync 148 """ 149 150 self._connector_state_manager.update_state_for_stream( 151 self._stream_name, self._stream_namespace, self.state 152 ) 153 state_message = self._connector_state_manager.create_state_message( 154 self._stream_name, self._stream_namespace 155 ) 156 self._message_repository.emit_message(state_message) 157 158 def should_be_synced(self, record: Record) -> bool: 159 return True 160 161 def get_cursor_datetime_from_state( 162 self, stream_state: Mapping[str, Any] 163 ) -> datetime.datetime | None: 164 """Return now() if state indicates a completed full refresh, else None. 165 166 When the state has NO_CURSOR_STATE_KEY: True, it means the previous sync was a 167 completed full refresh. Returning now() indicates the cursor is "current" and 168 within any retention period, so we should use incremental sync. 169 170 For any other state format, return None to indicate this cursor cannot parse it, 171 allowing the incremental cursor to handle the state instead. 172 """ 173 if stream_state.get(NO_CURSOR_STATE_KEY): 174 return datetime.datetime.now(datetime.timezone.utc) 175 return None 176 177 178class ConcurrentCursor(Cursor): 179 _START_BOUNDARY = 0 180 _END_BOUNDARY = 1 181 182 def copy_without_state(self) -> "ConcurrentCursor": 183 return self.__class__( 184 stream_name=self._stream_name, 185 stream_namespace=self._stream_namespace, 186 stream_state={}, 187 message_repository=NoopMessageRepository(), 188 connector_state_manager=ConnectorStateManager(), 189 connector_state_converter=self._connector_state_converter, 190 cursor_field=self._cursor_field, 191 slice_boundary_fields=self._slice_boundary_fields, 192 start=self._start, 193 end_provider=self._end_provider, 194 lookback_window=self._lookback_window, 195 slice_range=self._slice_range, 196 cursor_granularity=self._cursor_granularity, 197 clamping_strategy=self._clamping_strategy, 198 is_compare_strictly=self._is_compare_strictly, 199 ) 200 201 def __init__( 202 self, 203 stream_name: str, 204 stream_namespace: Optional[str], 205 stream_state: Any, 206 message_repository: MessageRepository, 207 connector_state_manager: ConnectorStateManager, 208 connector_state_converter: AbstractStreamStateConverter, 209 cursor_field: CursorField, 210 slice_boundary_fields: Optional[Tuple[str, str]], 211 start: Optional[CursorValueType], 212 end_provider: Callable[[], CursorValueType], 213 lookback_window: Optional[GapType] = None, 214 slice_range: Optional[GapType] = None, 215 cursor_granularity: Optional[GapType] = None, 216 clamping_strategy: ClampingStrategy = NoClamping(), 217 is_compare_strictly: bool = False, 218 ) -> None: 219 self._stream_name = stream_name 220 self._stream_namespace = stream_namespace 221 self._message_repository = message_repository 222 self._connector_state_converter = connector_state_converter 223 self._connector_state_manager = connector_state_manager 224 self._cursor_field = cursor_field 225 # To see some example where the slice boundaries might not be defined, check https://github.com/airbytehq/airbyte/blob/1ce84d6396e446e1ac2377362446e3fb94509461/airbyte-integrations/connectors/source-stripe/source_stripe/streams.py#L363-L379 226 self._slice_boundary_fields = slice_boundary_fields 227 self._start = start 228 self._end_provider = end_provider 229 self.start, self._concurrent_state = self._get_concurrent_state(stream_state) 230 self._lookback_window = lookback_window 231 self._slice_range = slice_range 232 self._most_recent_cursor_value_per_partition: MutableMapping[ 233 Union[StreamSlice, Mapping[str, Any], None], Any 234 ] = {} 235 self._has_closed_at_least_one_slice = False 236 self._cursor_granularity = cursor_granularity 237 # Flag to track if the logger has been triggered (per stream) 238 self._should_be_synced_logger_triggered = False 239 self._clamping_strategy = clamping_strategy 240 self._is_compare_strictly = is_compare_strictly 241 self._is_ascending_order = True 242 243 # A lock is required when closing a partition because updating the cursor's concurrent_state is 244 # not thread safe. When multiple partitions are being closed by the cursor at the same time, it is 245 # possible for one partition to update concurrent_state after a second partition has already read 246 # the previous state. This can lead to the second partition overwriting the previous one's state. 247 self._lock = threading.Lock() 248 249 @property 250 def state(self) -> MutableMapping[str, Any]: 251 return self._connector_state_converter.convert_to_state_message( 252 self.cursor_field, self._concurrent_state 253 ) 254 255 @property 256 def cursor_field(self) -> CursorField: 257 return self._cursor_field 258 259 @property 260 def _slice_boundary_fields_wrapper(self) -> Tuple[str, str]: 261 return ( 262 self._slice_boundary_fields 263 if self._slice_boundary_fields 264 else ( 265 self._connector_state_converter.START_KEY, 266 self._connector_state_converter.END_KEY, 267 ) 268 ) 269 270 def _get_concurrent_state( 271 self, state: MutableMapping[str, Any] 272 ) -> Tuple[CursorValueType, MutableMapping[str, Any]]: 273 if self._connector_state_converter.is_state_message_compatible(state): 274 partitioned_state = self._connector_state_converter.deserialize(state) 275 slices_from_partitioned_state = partitioned_state.get("slices", []) 276 277 value_from_partitioned_state = None 278 if slices_from_partitioned_state: 279 # We assume here that the slices have been already merged 280 first_slice = slices_from_partitioned_state[0] 281 value_from_partitioned_state = ( 282 first_slice[self._connector_state_converter.MOST_RECENT_RECORD_KEY] 283 if self._connector_state_converter.MOST_RECENT_RECORD_KEY in first_slice 284 else first_slice[self._connector_state_converter.END_KEY] 285 ) 286 return ( 287 value_from_partitioned_state 288 or self._start 289 or self._connector_state_converter.zero_value, 290 partitioned_state, 291 ) 292 return self._connector_state_converter.convert_from_sequential_state( 293 self._cursor_field, state, self._start 294 ) 295 296 def observe(self, record: Record) -> None: 297 # Because observe writes to the most_recent_cursor_value_per_partition mapping, 298 # it is not thread-safe. However, this shouldn't lead to concurrency issues because 299 # observe() is only invoked by PartitionReader.process_partition(). Since the map is 300 # broken down according to partition, concurrent threads processing only read/write 301 # from different keys which avoids any conflicts. 302 # 303 # If we were to add thread safety, we should implement a lock per-partition 304 # which is instantiated during stream_slices() 305 most_recent_cursor_value = self._most_recent_cursor_value_per_partition.get( 306 record.associated_slice 307 ) 308 try: 309 cursor_value = self._extract_cursor_value(record) 310 311 if most_recent_cursor_value is None or most_recent_cursor_value < cursor_value: 312 self._most_recent_cursor_value_per_partition[record.associated_slice] = cursor_value 313 elif most_recent_cursor_value > cursor_value: 314 self._is_ascending_order = False 315 except ValueError: 316 self._log_for_record_without_cursor_value() 317 318 def _extract_cursor_value(self, record: Record) -> Any: 319 return self._connector_state_converter.parse_value(self._cursor_field.extract_value(record)) 320 321 def close_partition(self, partition: Partition) -> None: 322 with self._lock: 323 slice_count_before = len(self._concurrent_state.get("slices", [])) 324 self._add_slice_to_state(partition) 325 if slice_count_before < len( 326 self._concurrent_state["slices"] 327 ): # only emit if at least one slice has been processed 328 self._merge_partitions() 329 self._emit_state_message() 330 self._has_closed_at_least_one_slice = True 331 332 def _add_slice_to_state(self, partition: Partition) -> None: 333 most_recent_cursor_value = self._most_recent_cursor_value_per_partition.get( 334 partition.to_slice() 335 ) 336 337 if self._slice_boundary_fields: 338 if "slices" not in self._concurrent_state: 339 raise RuntimeError( 340 f"The state for stream {self._stream_name} should have at least one slice to delineate the sync start time, but no slices are present. This is unexpected. Please contact Support." 341 ) 342 self._concurrent_state["slices"].append( 343 { 344 self._connector_state_converter.START_KEY: self._extract_from_slice( 345 partition, self._slice_boundary_fields[self._START_BOUNDARY] 346 ), 347 self._connector_state_converter.END_KEY: self._extract_from_slice( 348 partition, self._slice_boundary_fields[self._END_BOUNDARY] 349 ), 350 self._connector_state_converter.MOST_RECENT_RECORD_KEY: most_recent_cursor_value, 351 } 352 ) 353 elif most_recent_cursor_value: 354 if self._has_closed_at_least_one_slice: 355 # If we track state value using records cursor field, we can only do that if there is one partition. This is because we save 356 # the state every time we close a partition. We assume that if there are multiple slices, they need to be providing 357 # boundaries. There are cases where partitions could not have boundaries: 358 # * The cursor should be per-partition 359 # * The stream state is actually the parent stream state 360 # There might be other cases not listed above. Those are not supported today hence the stream should not use this cursor for 361 # state management. For the specific user that was affected with this issue, we need to: 362 # * Fix state tracking (which is currently broken) 363 # * Make the new version available 364 # * (Probably) ask the user to reset the stream to avoid data loss 365 raise ValueError( 366 "Given that slice_boundary_fields is not defined and that per-partition state is not supported, only one slice is " 367 "expected. Please contact the Airbyte team." 368 ) 369 370 self._concurrent_state["slices"].append( 371 { 372 self._connector_state_converter.START_KEY: self.start, 373 self._connector_state_converter.END_KEY: most_recent_cursor_value, 374 self._connector_state_converter.MOST_RECENT_RECORD_KEY: most_recent_cursor_value, 375 } 376 ) 377 378 def _emit_state_message(self) -> None: 379 self._connector_state_manager.update_state_for_stream( 380 self._stream_name, 381 self._stream_namespace, 382 self.state, 383 ) 384 state_message = self._connector_state_manager.create_state_message( 385 self._stream_name, self._stream_namespace 386 ) 387 self._message_repository.emit_message(state_message) 388 389 def _merge_partitions(self) -> None: 390 self._concurrent_state["slices"] = self._connector_state_converter.merge_intervals( 391 self._concurrent_state["slices"] 392 ) 393 394 def _extract_from_slice(self, partition: Partition, key: str) -> CursorValueType: 395 try: 396 _slice = partition.to_slice() 397 if not _slice: 398 raise KeyError(f"Could not find key `{key}` in empty slice") 399 return self._connector_state_converter.parse_value(_slice[key]) # type: ignore # we expect the devs to specify a key that would return a CursorValueType 400 except KeyError as exception: 401 raise KeyError( 402 f"Partition is expected to have key `{key}` but could not be found" 403 ) from exception 404 405 def ensure_at_least_one_state_emitted(self) -> None: 406 """ 407 The platform expect to have at least one state message on successful syncs. Hence, whatever happens, we expect this method to be 408 called. 409 """ 410 self._emit_state_message() 411 412 def stream_slices(self) -> Iterable[StreamSlice]: 413 """ 414 Generating slices based on a few parameters: 415 * lookback_window: Buffer to remove from END_KEY of the highest slice 416 * slice_range: Max difference between two slices. If the difference between two slices is greater, multiple slices will be created 417 * start: `_split_per_slice_range` will clip any value to `self._start which means that: 418 * if upper is less than self._start, no slices will be generated 419 * if lower is less than self._start, self._start will be used as the lower boundary (lookback_window will not be considered in that case) 420 421 Note that the slices will overlap at their boundaries. We therefore expect to have at least the lower or the upper boundary to be 422 inclusive in the API that is queried. 423 """ 424 self._merge_partitions() 425 426 if self._start is not None and self._is_start_before_first_slice(): 427 yield from self._split_per_slice_range( 428 self._start, 429 self._concurrent_state["slices"][0][self._connector_state_converter.START_KEY], 430 False, 431 ) 432 433 if len(self._concurrent_state["slices"]) == 1: 434 yield from self._split_per_slice_range( 435 self._calculate_lower_boundary_of_last_slice( 436 self._concurrent_state["slices"][0][self._connector_state_converter.END_KEY] 437 ), 438 self._end_provider(), 439 True, 440 ) 441 elif len(self._concurrent_state["slices"]) > 1: 442 for i in range(len(self._concurrent_state["slices"]) - 1): 443 if self._cursor_granularity: 444 yield from self._split_per_slice_range( 445 self._concurrent_state["slices"][i][self._connector_state_converter.END_KEY] 446 + self._cursor_granularity, 447 self._concurrent_state["slices"][i + 1][ 448 self._connector_state_converter.START_KEY 449 ], 450 False, 451 ) 452 else: 453 yield from self._split_per_slice_range( 454 self._concurrent_state["slices"][i][ 455 self._connector_state_converter.END_KEY 456 ], 457 self._concurrent_state["slices"][i + 1][ 458 self._connector_state_converter.START_KEY 459 ], 460 False, 461 ) 462 yield from self._split_per_slice_range( 463 self._calculate_lower_boundary_of_last_slice( 464 self._concurrent_state["slices"][-1][self._connector_state_converter.END_KEY] 465 ), 466 self._end_provider(), 467 True, 468 ) 469 else: 470 raise ValueError("Expected at least one slice") 471 472 def _is_start_before_first_slice(self) -> bool: 473 return ( 474 self._start is not None 475 and self._start 476 < self._concurrent_state["slices"][0][self._connector_state_converter.START_KEY] 477 ) 478 479 def _calculate_lower_boundary_of_last_slice( 480 self, lower_boundary: CursorValueType 481 ) -> CursorValueType: 482 if self._lookback_window: 483 return lower_boundary - self._lookback_window 484 return lower_boundary 485 486 def _split_per_slice_range( 487 self, lower: CursorValueType, upper: CursorValueType, upper_is_end: bool 488 ) -> Iterable[StreamSlice]: 489 if lower > upper or (lower == upper and not self._cursor_granularity): 490 return 491 492 if self._start and upper < self._start: 493 return 494 495 lower = max(lower, self._start) if self._start else lower 496 # Clamp first, else a step shorter than the clamping period collapses the first window 497 lower = self._clamping_strategy.clamp(lower) 498 # With a granularity the last window includes upper, so lower == upper is one unit to read, 499 # unless the API rejects start == end. In a gap, the next slice already reads upper. 500 if lower > upper or (lower == upper and (self._is_compare_strictly or not upper_is_end)): 501 return 502 if not self._slice_range or self._evaluate_upper_safely(lower, self._slice_range) >= upper: 503 # With a granularity upper is the last unit to read: clamping it would read past the end 504 clamped_upper = ( 505 upper 506 if upper_is_end and self._cursor_granularity 507 else self._clamping_strategy.clamp(upper) 508 ) 509 start_value, end_value = ( 510 (lower, clamped_upper - self._cursor_granularity) 511 if self._cursor_granularity and not upper_is_end 512 else (lower, clamped_upper) 513 ) 514 yield StreamSlice( 515 partition={}, 516 cursor_slice={ 517 self._slice_boundary_fields_wrapper[ 518 self._START_BOUNDARY 519 ]: self._connector_state_converter.output_format(start_value), 520 self._slice_boundary_fields_wrapper[ 521 self._END_BOUNDARY 522 ]: self._connector_state_converter.output_format(end_value), 523 }, 524 ) 525 else: 526 stop_processing = False 527 current_lower_boundary = lower 528 while not stop_processing: 529 current_upper_boundary = min( 530 self._evaluate_upper_safely(current_lower_boundary, self._slice_range), upper 531 ) 532 clamped_upper = ( 533 self._clamping_strategy.clamp(current_upper_boundary) 534 if current_upper_boundary != upper 535 else current_upper_boundary 536 ) 537 # A boundary clamped onto upper makes this the last window, else upper is dropped 538 has_reached_upper_boundary = ( 539 current_upper_boundary >= upper or clamped_upper == upper 540 ) 541 clamped_lower = self._clamping_strategy.clamp(current_lower_boundary) 542 if clamped_lower >= clamped_upper: 543 # clamping collapsed both values which means that it is time to stop processing 544 # FIXME should this be replace by proper end_provider 545 break 546 start_value, end_value = ( 547 (clamped_lower, clamped_upper - self._cursor_granularity) 548 if self._cursor_granularity 549 and (not upper_is_end or not has_reached_upper_boundary) 550 else (clamped_lower, clamped_upper) 551 ) 552 yield StreamSlice( 553 partition={}, 554 cursor_slice={ 555 self._slice_boundary_fields_wrapper[ 556 self._START_BOUNDARY 557 ]: self._connector_state_converter.output_format(start_value), 558 self._slice_boundary_fields_wrapper[ 559 self._END_BOUNDARY 560 ]: self._connector_state_converter.output_format(end_value), 561 }, 562 ) 563 current_lower_boundary = clamped_upper 564 if has_reached_upper_boundary: 565 stop_processing = True 566 567 def _evaluate_upper_safely(self, lower: CursorValueType, step: GapType) -> CursorValueType: 568 """ 569 Given that we set the default step at datetime.timedelta.max, we will generate an OverflowError when evaluating the next start_date 570 This method assumes that users would never enter a step that would generate an overflow. Given that would be the case, the code 571 would have broken anyway. 572 """ 573 try: 574 return lower + step 575 except OverflowError: 576 return self._end_provider() 577 578 def should_be_synced(self, record: Record) -> bool: 579 """ 580 Determines if a record should be synced based on its cursor value. 581 :param record: The record to evaluate 582 583 :return: True if the record's cursor value falls within the sync boundaries 584 """ 585 try: 586 record_cursor_value: CursorValueType = self._extract_cursor_value(record) 587 except ValueError: 588 self._log_for_record_without_cursor_value() 589 return True 590 return self._lowest_cursor_value_to_sync() <= record_cursor_value <= self._end_provider() 591 592 def _lowest_cursor_value_to_sync(self) -> CursorValueType: 593 # Keep the records the lookback window re-reads, never below the configured start. If the state is below the 594 # configured start, keep records from the state rather than raising the floor to the configured start. 595 if not self._lookback_window: 596 return self.start 597 try: 598 lower = self.start - self._lookback_window 599 except OverflowError: 600 return self.start 601 return max(lower, min(self.start, self._start)) if self._start else lower 602 603 def _log_for_record_without_cursor_value(self) -> None: 604 if not self._should_be_synced_logger_triggered: 605 LOGGER.warning( 606 f"Could not find cursor field `{self.cursor_field.cursor_field_key}` in record for stream {self._stream_name}. The incremental sync will assume it needs to be synced" 607 ) 608 self._should_be_synced_logger_triggered = True 609 610 def reduce_slice_range(self, stream_slice: StreamSlice) -> StreamSlice: 611 # In theory, we might be more flexible here meaning that it doesn't need to be in ascending order but it just 612 # needs to be ordered. For now though, we will only support ascending order. 613 if not self._is_ascending_order: 614 LOGGER.warning( 615 "Attempting to reduce slice while records are not returned in incremental order might lead to missing records" 616 ) 617 618 if stream_slice in self._most_recent_cursor_value_per_partition: 619 return StreamSlice( 620 partition=stream_slice.partition, 621 cursor_slice={ 622 self._slice_boundary_fields_wrapper[ 623 self._START_BOUNDARY 624 ]: self._connector_state_converter.output_format( 625 self._most_recent_cursor_value_per_partition[stream_slice] 626 ), 627 self._slice_boundary_fields_wrapper[ 628 self._END_BOUNDARY 629 ]: stream_slice.cursor_slice[ 630 self._slice_boundary_fields_wrapper[self._END_BOUNDARY] 631 ], 632 }, 633 extra_fields=stream_slice.extra_fields, 634 ) 635 else: 636 return stream_slice 637 638 def split_request_window( 639 self, stream_slice: StreamSlice, min_split_window: Optional[datetime.timedelta] = None 640 ) -> Optional[List[StreamSlice]]: 641 """ 642 Split `stream_slice` in half along this cursor's own boundary fields, granularity, and output format. 643 644 Returns `None` when the slice cannot be split any further: either it spans at most one 645 `cursor_granularity` unit already (the floor), it is already at or below `min_split_window` (a 646 connector-configured floor independent of and typically looser than `cursor_granularity`), the 647 boundaries are reversed, or no `cursor_granularity` was configured at all (there is then no way to know 648 the smallest addressable unit, or how to keep two children from overlapping at their shared edge). 649 Raises `AirbyteTracedException` instead if a boundary is missing or unparseable: that is a bug in how 650 the slice was produced, not the API rejecting a window, so it must not be reported as one. This is the 651 implementation `SimpleRetriever.request_window_splitter` binds to (see 652 `airbyte_cdk.sources.declarative.retrievers.simple_retriever`) that reuses the same 653 parsing/formatting/comparison operations `_split_per_slice_range` already relies on, so it works for 654 any `CursorValueType`/`GapType` pair this cursor was built with (datetime/timedelta, or int/int), 655 without a second, duplicate implementation of that logic living outside the cursor. 656 """ 657 if not self._cursor_granularity: 658 return None 659 660 start_field, end_field = self._slice_boundary_fields_wrapper 661 try: 662 start_value = self._connector_state_converter.parse_value( 663 stream_slice.cursor_slice[start_field] 664 ) 665 end_value = self._connector_state_converter.parse_value( 666 stream_slice.cursor_slice[end_field] 667 ) 668 except (KeyError, ValueError, TypeError) as exception: 669 # A missing or unparseable boundary means this slice was produced (or passed in) wrong - a bug in 670 # slice generation, not something the API did. Raising here, rather than returning `None` (which 671 # `SimpleRetriever` reports as "the API kept rejecting..."), keeps that distinction visible instead 672 # of blaming the API for our own state. 673 raise AirbyteTracedException( 674 internal_message=( 675 f"Cannot split {stream_slice}: boundary field {start_field!r} or {end_field!r} is missing " 676 f"or not parseable: {exception}" 677 ), 678 message=( 679 f"Stream {self._stream_name} produced a request window with a missing or invalid " 680 f"{start_field!r}/{end_field!r} boundary and could not split it further." 681 ), 682 failure_type=FailureType.system_error, 683 ) from exception 684 685 if start_value >= end_value: 686 # Reversed or degenerate boundaries: nothing safe to split. 687 return None 688 689 span = end_value - start_value # type: ignore[operator] # concretely a GapType (timedelta/int) at runtime 690 if min_split_window is not None and span <= min_split_window: # type: ignore[operator] 691 return None 692 693 total_units = span // self._cursor_granularity + 1 # type: ignore[operator] # inclusive count of granularity units 694 if total_units <= 1: 695 # Already at the minimum granularity and still rejected; nothing left to split. 696 return None 697 698 half_units = total_units // 2 699 first_end = ( 700 start_value + (half_units * self._cursor_granularity) - self._cursor_granularity # type: ignore[operator] 701 ) 702 second_start = first_end + self._cursor_granularity 703 704 if not (start_value <= first_end < second_start <= end_value): 705 # No-progress guard, independent of the granularity check above: refuse to return children that 706 # are not strictly ordered and non-degenerate, rather than risk an identical or overlapping split. 707 return None 708 709 return [ 710 StreamSlice( 711 partition=stream_slice.partition, 712 cursor_slice={ 713 start_field: self._connector_state_converter.output_format(start_value), 714 end_field: self._connector_state_converter.output_format(first_end), 715 }, 716 extra_fields=stream_slice.extra_fields, 717 ), 718 StreamSlice( 719 partition=stream_slice.partition, 720 cursor_slice={ 721 start_field: self._connector_state_converter.output_format(second_start), 722 end_field: self._connector_state_converter.output_format(end_value), 723 }, 724 extra_fields=stream_slice.extra_fields, 725 ), 726 ] 727 728 def get_cursor_datetime_from_state( 729 self, stream_state: Mapping[str, Any] 730 ) -> datetime.datetime | None: 731 """Extract and parse the cursor datetime from the given stream state. 732 733 For concurrent cursors, the state can be in two formats: 734 1. Sequential/legacy format: {cursor_field: cursor_value} 735 2. Concurrent format: {state_type: "date-range", slices: [...]} 736 737 Returns the cursor datetime if present and parseable, otherwise returns None. 738 """ 739 # Check if state is in concurrent format (need to convert to dict for type compatibility) 740 mutable_state: MutableMapping[str, Any] = dict(stream_state) 741 if self._connector_state_converter.is_state_message_compatible(mutable_state): 742 slices = stream_state.get("slices", []) 743 if not slices: 744 return None 745 # Get the most recent cursor value from the first slice (after merging) 746 first_slice = slices[0] 747 cursor_value = first_slice.get( 748 self._connector_state_converter.MOST_RECENT_RECORD_KEY 749 ) or first_slice.get(self._connector_state_converter.END_KEY) 750 if not cursor_value: 751 return None 752 try: 753 parsed_value = self._connector_state_converter.parse_value(cursor_value) 754 if isinstance(parsed_value, datetime.datetime): 755 return parsed_value 756 return None 757 except (ValueError, TypeError): 758 return None 759 760 # Sequential/legacy format: {cursor_field: cursor_value} 761 cursor_value = stream_state.get(self._cursor_field.cursor_field_key) 762 if not cursor_value: 763 return None 764 try: 765 parsed_value = self._connector_state_converter.parse_value(cursor_value) 766 if isinstance(parsed_value, datetime.datetime): 767 return parsed_value 768 return None 769 except (ValueError, TypeError): 770 return None
44class CursorField: 45 def __init__( 46 self, cursor_field_key: str, supports_catalog_defined_cursor_field: bool = False 47 ) -> None: 48 self.cursor_field_key = cursor_field_key 49 self.supports_catalog_defined_cursor_field = supports_catalog_defined_cursor_field 50 51 def extract_value(self, record: Record) -> Any: 52 cursor_value = record.data.get(self.cursor_field_key) 53 if cursor_value is None: 54 raise ValueError(f"Could not find cursor field {self.cursor_field_key} in record") 55 return cursor_value # type: ignore # we assume that the value the path points at is a comparable
51 def extract_value(self, record: Record) -> Any: 52 cursor_value = record.data.get(self.cursor_field_key) 53 if cursor_value is None: 54 raise ValueError(f"Could not find cursor field {self.cursor_field_key} in record") 55 return cursor_value # type: ignore # we assume that the value the path points at is a comparable
58class Cursor(StreamSlicer, ABC): 59 @property 60 @abstractmethod 61 def state(self) -> MutableMapping[str, Any]: ... 62 63 @abstractmethod 64 def observe(self, record: Record) -> None: 65 """ 66 Indicate to the cursor that the record has been emitted 67 """ 68 raise NotImplementedError() 69 70 @abstractmethod 71 def close_partition(self, partition: Partition) -> None: 72 """ 73 Indicate to the cursor that the partition has been successfully processed 74 """ 75 raise NotImplementedError() 76 77 @abstractmethod 78 def ensure_at_least_one_state_emitted(self) -> None: 79 """ 80 State messages are emitted when a partition is closed. However, the platform expects at least one state to be emitted per sync per 81 stream. Hence, if no partitions are generated, this method needs to be called. 82 """ 83 raise NotImplementedError() 84 85 @abstractmethod 86 def should_be_synced(self, record: Record) -> bool: 87 pass 88 89 def stream_slices(self) -> Iterable[StreamSlice]: 90 """ 91 Default placeholder implementation of generate_slices. 92 Subclasses can override this method to provide actual behavior. 93 """ 94 yield StreamSlice(partition={}, cursor_slice={}) 95 96 def get_cursor_datetime_from_state( 97 self, stream_state: Mapping[str, Any] 98 ) -> datetime.datetime | None: 99 """Extract and parse the cursor datetime from the given stream state. 100 101 This method is used by StateDelegatingStream to validate cursor age against 102 an API's data retention period. Subclasses should implement this method to 103 extract the cursor value from their specific state structure and parse it 104 into a datetime object. 105 106 Returns None if the cursor cannot be extracted or parsed, which will cause 107 StateDelegatingStream to fall back to full refresh (safe default). 108 109 Raises NotImplementedError by default - subclasses must implement this method 110 if they want to support cursor age validation with api_retention_period. 111 """ 112 raise NotImplementedError( 113 f"{self.__class__.__name__} does not implement get_cursor_datetime_from_state. " 114 f"Cursor age validation with api_retention_period is not supported for this cursor type." 115 )
Slices the stream into chunks that can be fetched independently. Slices enable state checkpointing and data retrieval parallelization.
63 @abstractmethod 64 def observe(self, record: Record) -> None: 65 """ 66 Indicate to the cursor that the record has been emitted 67 """ 68 raise NotImplementedError()
Indicate to the cursor that the record has been emitted
70 @abstractmethod 71 def close_partition(self, partition: Partition) -> None: 72 """ 73 Indicate to the cursor that the partition has been successfully processed 74 """ 75 raise NotImplementedError()
Indicate to the cursor that the partition has been successfully processed
77 @abstractmethod 78 def ensure_at_least_one_state_emitted(self) -> None: 79 """ 80 State messages are emitted when a partition is closed. However, the platform expects at least one state to be emitted per sync per 81 stream. Hence, if no partitions are generated, this method needs to be called. 82 """ 83 raise NotImplementedError()
State messages are emitted when a partition is closed. However, the platform expects at least one state to be emitted per sync per stream. Hence, if no partitions are generated, this method needs to be called.
89 def stream_slices(self) -> Iterable[StreamSlice]: 90 """ 91 Default placeholder implementation of generate_slices. 92 Subclasses can override this method to provide actual behavior. 93 """ 94 yield StreamSlice(partition={}, cursor_slice={})
Default placeholder implementation of generate_slices. Subclasses can override this method to provide actual behavior.
96 def get_cursor_datetime_from_state( 97 self, stream_state: Mapping[str, Any] 98 ) -> datetime.datetime | None: 99 """Extract and parse the cursor datetime from the given stream state. 100 101 This method is used by StateDelegatingStream to validate cursor age against 102 an API's data retention period. Subclasses should implement this method to 103 extract the cursor value from their specific state structure and parse it 104 into a datetime object. 105 106 Returns None if the cursor cannot be extracted or parsed, which will cause 107 StateDelegatingStream to fall back to full refresh (safe default). 108 109 Raises NotImplementedError by default - subclasses must implement this method 110 if they want to support cursor age validation with api_retention_period. 111 """ 112 raise NotImplementedError( 113 f"{self.__class__.__name__} does not implement get_cursor_datetime_from_state. " 114 f"Cursor age validation with api_retention_period is not supported for this cursor type." 115 )
Extract and parse the cursor datetime from the given stream state.
This method is used by StateDelegatingStream to validate cursor age against an API's data retention period. Subclasses should implement this method to extract the cursor value from their specific state structure and parse it into a datetime object.
Returns None if the cursor cannot be extracted or parsed, which will cause StateDelegatingStream to fall back to full refresh (safe default).
Raises NotImplementedError by default - subclasses must implement this method if they want to support cursor age validation with api_retention_period.
118class FinalStateCursor(Cursor): 119 """Cursor that is used to guarantee at least one state message is emitted for a concurrent stream.""" 120 121 def __init__( 122 self, 123 stream_name: str, 124 stream_namespace: Optional[str], 125 message_repository: MessageRepository, 126 ) -> None: 127 self._stream_name = stream_name 128 self._stream_namespace = stream_namespace 129 self._message_repository = message_repository 130 # Normally the connector state manager operates at the source-level. However, we only need it to write the sentinel 131 # state message rather than manage overall source state. This is also only temporary as we move to the resumable 132 # full refresh world where every stream uses a FileBasedConcurrentCursor with incremental state. 133 self._connector_state_manager = ConnectorStateManager() 134 self._has_closed_at_least_one_slice = False 135 136 @property 137 def state(self) -> MutableMapping[str, Any]: 138 return {NO_CURSOR_STATE_KEY: True} 139 140 def observe(self, record: Record) -> None: 141 pass 142 143 def close_partition(self, partition: Partition) -> None: 144 pass 145 146 def ensure_at_least_one_state_emitted(self) -> None: 147 """ 148 Used primarily for full refresh syncs that do not have a valid cursor value to emit at the end of a sync 149 """ 150 151 self._connector_state_manager.update_state_for_stream( 152 self._stream_name, self._stream_namespace, self.state 153 ) 154 state_message = self._connector_state_manager.create_state_message( 155 self._stream_name, self._stream_namespace 156 ) 157 self._message_repository.emit_message(state_message) 158 159 def should_be_synced(self, record: Record) -> bool: 160 return True 161 162 def get_cursor_datetime_from_state( 163 self, stream_state: Mapping[str, Any] 164 ) -> datetime.datetime | None: 165 """Return now() if state indicates a completed full refresh, else None. 166 167 When the state has NO_CURSOR_STATE_KEY: True, it means the previous sync was a 168 completed full refresh. Returning now() indicates the cursor is "current" and 169 within any retention period, so we should use incremental sync. 170 171 For any other state format, return None to indicate this cursor cannot parse it, 172 allowing the incremental cursor to handle the state instead. 173 """ 174 if stream_state.get(NO_CURSOR_STATE_KEY): 175 return datetime.datetime.now(datetime.timezone.utc) 176 return None
Cursor that is used to guarantee at least one state message is emitted for a concurrent stream.
121 def __init__( 122 self, 123 stream_name: str, 124 stream_namespace: Optional[str], 125 message_repository: MessageRepository, 126 ) -> None: 127 self._stream_name = stream_name 128 self._stream_namespace = stream_namespace 129 self._message_repository = message_repository 130 # Normally the connector state manager operates at the source-level. However, we only need it to write the sentinel 131 # state message rather than manage overall source state. This is also only temporary as we move to the resumable 132 # full refresh world where every stream uses a FileBasedConcurrentCursor with incremental state. 133 self._connector_state_manager = ConnectorStateManager() 134 self._has_closed_at_least_one_slice = False
Indicate to the cursor that the partition has been successfully processed
146 def ensure_at_least_one_state_emitted(self) -> None: 147 """ 148 Used primarily for full refresh syncs that do not have a valid cursor value to emit at the end of a sync 149 """ 150 151 self._connector_state_manager.update_state_for_stream( 152 self._stream_name, self._stream_namespace, self.state 153 ) 154 state_message = self._connector_state_manager.create_state_message( 155 self._stream_name, self._stream_namespace 156 ) 157 self._message_repository.emit_message(state_message)
Used primarily for full refresh syncs that do not have a valid cursor value to emit at the end of a sync
162 def get_cursor_datetime_from_state( 163 self, stream_state: Mapping[str, Any] 164 ) -> datetime.datetime | None: 165 """Return now() if state indicates a completed full refresh, else None. 166 167 When the state has NO_CURSOR_STATE_KEY: True, it means the previous sync was a 168 completed full refresh. Returning now() indicates the cursor is "current" and 169 within any retention period, so we should use incremental sync. 170 171 For any other state format, return None to indicate this cursor cannot parse it, 172 allowing the incremental cursor to handle the state instead. 173 """ 174 if stream_state.get(NO_CURSOR_STATE_KEY): 175 return datetime.datetime.now(datetime.timezone.utc) 176 return None
Return now() if state indicates a completed full refresh, else None.
When the state has NO_CURSOR_STATE_KEY: True, it means the previous sync was a completed full refresh. Returning now() indicates the cursor is "current" and within any retention period, so we should use incremental sync.
For any other state format, return None to indicate this cursor cannot parse it, allowing the incremental cursor to handle the state instead.
Inherited Members
179class ConcurrentCursor(Cursor): 180 _START_BOUNDARY = 0 181 _END_BOUNDARY = 1 182 183 def copy_without_state(self) -> "ConcurrentCursor": 184 return self.__class__( 185 stream_name=self._stream_name, 186 stream_namespace=self._stream_namespace, 187 stream_state={}, 188 message_repository=NoopMessageRepository(), 189 connector_state_manager=ConnectorStateManager(), 190 connector_state_converter=self._connector_state_converter, 191 cursor_field=self._cursor_field, 192 slice_boundary_fields=self._slice_boundary_fields, 193 start=self._start, 194 end_provider=self._end_provider, 195 lookback_window=self._lookback_window, 196 slice_range=self._slice_range, 197 cursor_granularity=self._cursor_granularity, 198 clamping_strategy=self._clamping_strategy, 199 is_compare_strictly=self._is_compare_strictly, 200 ) 201 202 def __init__( 203 self, 204 stream_name: str, 205 stream_namespace: Optional[str], 206 stream_state: Any, 207 message_repository: MessageRepository, 208 connector_state_manager: ConnectorStateManager, 209 connector_state_converter: AbstractStreamStateConverter, 210 cursor_field: CursorField, 211 slice_boundary_fields: Optional[Tuple[str, str]], 212 start: Optional[CursorValueType], 213 end_provider: Callable[[], CursorValueType], 214 lookback_window: Optional[GapType] = None, 215 slice_range: Optional[GapType] = None, 216 cursor_granularity: Optional[GapType] = None, 217 clamping_strategy: ClampingStrategy = NoClamping(), 218 is_compare_strictly: bool = False, 219 ) -> None: 220 self._stream_name = stream_name 221 self._stream_namespace = stream_namespace 222 self._message_repository = message_repository 223 self._connector_state_converter = connector_state_converter 224 self._connector_state_manager = connector_state_manager 225 self._cursor_field = cursor_field 226 # To see some example where the slice boundaries might not be defined, check https://github.com/airbytehq/airbyte/blob/1ce84d6396e446e1ac2377362446e3fb94509461/airbyte-integrations/connectors/source-stripe/source_stripe/streams.py#L363-L379 227 self._slice_boundary_fields = slice_boundary_fields 228 self._start = start 229 self._end_provider = end_provider 230 self.start, self._concurrent_state = self._get_concurrent_state(stream_state) 231 self._lookback_window = lookback_window 232 self._slice_range = slice_range 233 self._most_recent_cursor_value_per_partition: MutableMapping[ 234 Union[StreamSlice, Mapping[str, Any], None], Any 235 ] = {} 236 self._has_closed_at_least_one_slice = False 237 self._cursor_granularity = cursor_granularity 238 # Flag to track if the logger has been triggered (per stream) 239 self._should_be_synced_logger_triggered = False 240 self._clamping_strategy = clamping_strategy 241 self._is_compare_strictly = is_compare_strictly 242 self._is_ascending_order = True 243 244 # A lock is required when closing a partition because updating the cursor's concurrent_state is 245 # not thread safe. When multiple partitions are being closed by the cursor at the same time, it is 246 # possible for one partition to update concurrent_state after a second partition has already read 247 # the previous state. This can lead to the second partition overwriting the previous one's state. 248 self._lock = threading.Lock() 249 250 @property 251 def state(self) -> MutableMapping[str, Any]: 252 return self._connector_state_converter.convert_to_state_message( 253 self.cursor_field, self._concurrent_state 254 ) 255 256 @property 257 def cursor_field(self) -> CursorField: 258 return self._cursor_field 259 260 @property 261 def _slice_boundary_fields_wrapper(self) -> Tuple[str, str]: 262 return ( 263 self._slice_boundary_fields 264 if self._slice_boundary_fields 265 else ( 266 self._connector_state_converter.START_KEY, 267 self._connector_state_converter.END_KEY, 268 ) 269 ) 270 271 def _get_concurrent_state( 272 self, state: MutableMapping[str, Any] 273 ) -> Tuple[CursorValueType, MutableMapping[str, Any]]: 274 if self._connector_state_converter.is_state_message_compatible(state): 275 partitioned_state = self._connector_state_converter.deserialize(state) 276 slices_from_partitioned_state = partitioned_state.get("slices", []) 277 278 value_from_partitioned_state = None 279 if slices_from_partitioned_state: 280 # We assume here that the slices have been already merged 281 first_slice = slices_from_partitioned_state[0] 282 value_from_partitioned_state = ( 283 first_slice[self._connector_state_converter.MOST_RECENT_RECORD_KEY] 284 if self._connector_state_converter.MOST_RECENT_RECORD_KEY in first_slice 285 else first_slice[self._connector_state_converter.END_KEY] 286 ) 287 return ( 288 value_from_partitioned_state 289 or self._start 290 or self._connector_state_converter.zero_value, 291 partitioned_state, 292 ) 293 return self._connector_state_converter.convert_from_sequential_state( 294 self._cursor_field, state, self._start 295 ) 296 297 def observe(self, record: Record) -> None: 298 # Because observe writes to the most_recent_cursor_value_per_partition mapping, 299 # it is not thread-safe. However, this shouldn't lead to concurrency issues because 300 # observe() is only invoked by PartitionReader.process_partition(). Since the map is 301 # broken down according to partition, concurrent threads processing only read/write 302 # from different keys which avoids any conflicts. 303 # 304 # If we were to add thread safety, we should implement a lock per-partition 305 # which is instantiated during stream_slices() 306 most_recent_cursor_value = self._most_recent_cursor_value_per_partition.get( 307 record.associated_slice 308 ) 309 try: 310 cursor_value = self._extract_cursor_value(record) 311 312 if most_recent_cursor_value is None or most_recent_cursor_value < cursor_value: 313 self._most_recent_cursor_value_per_partition[record.associated_slice] = cursor_value 314 elif most_recent_cursor_value > cursor_value: 315 self._is_ascending_order = False 316 except ValueError: 317 self._log_for_record_without_cursor_value() 318 319 def _extract_cursor_value(self, record: Record) -> Any: 320 return self._connector_state_converter.parse_value(self._cursor_field.extract_value(record)) 321 322 def close_partition(self, partition: Partition) -> None: 323 with self._lock: 324 slice_count_before = len(self._concurrent_state.get("slices", [])) 325 self._add_slice_to_state(partition) 326 if slice_count_before < len( 327 self._concurrent_state["slices"] 328 ): # only emit if at least one slice has been processed 329 self._merge_partitions() 330 self._emit_state_message() 331 self._has_closed_at_least_one_slice = True 332 333 def _add_slice_to_state(self, partition: Partition) -> None: 334 most_recent_cursor_value = self._most_recent_cursor_value_per_partition.get( 335 partition.to_slice() 336 ) 337 338 if self._slice_boundary_fields: 339 if "slices" not in self._concurrent_state: 340 raise RuntimeError( 341 f"The state for stream {self._stream_name} should have at least one slice to delineate the sync start time, but no slices are present. This is unexpected. Please contact Support." 342 ) 343 self._concurrent_state["slices"].append( 344 { 345 self._connector_state_converter.START_KEY: self._extract_from_slice( 346 partition, self._slice_boundary_fields[self._START_BOUNDARY] 347 ), 348 self._connector_state_converter.END_KEY: self._extract_from_slice( 349 partition, self._slice_boundary_fields[self._END_BOUNDARY] 350 ), 351 self._connector_state_converter.MOST_RECENT_RECORD_KEY: most_recent_cursor_value, 352 } 353 ) 354 elif most_recent_cursor_value: 355 if self._has_closed_at_least_one_slice: 356 # If we track state value using records cursor field, we can only do that if there is one partition. This is because we save 357 # the state every time we close a partition. We assume that if there are multiple slices, they need to be providing 358 # boundaries. There are cases where partitions could not have boundaries: 359 # * The cursor should be per-partition 360 # * The stream state is actually the parent stream state 361 # There might be other cases not listed above. Those are not supported today hence the stream should not use this cursor for 362 # state management. For the specific user that was affected with this issue, we need to: 363 # * Fix state tracking (which is currently broken) 364 # * Make the new version available 365 # * (Probably) ask the user to reset the stream to avoid data loss 366 raise ValueError( 367 "Given that slice_boundary_fields is not defined and that per-partition state is not supported, only one slice is " 368 "expected. Please contact the Airbyte team." 369 ) 370 371 self._concurrent_state["slices"].append( 372 { 373 self._connector_state_converter.START_KEY: self.start, 374 self._connector_state_converter.END_KEY: most_recent_cursor_value, 375 self._connector_state_converter.MOST_RECENT_RECORD_KEY: most_recent_cursor_value, 376 } 377 ) 378 379 def _emit_state_message(self) -> None: 380 self._connector_state_manager.update_state_for_stream( 381 self._stream_name, 382 self._stream_namespace, 383 self.state, 384 ) 385 state_message = self._connector_state_manager.create_state_message( 386 self._stream_name, self._stream_namespace 387 ) 388 self._message_repository.emit_message(state_message) 389 390 def _merge_partitions(self) -> None: 391 self._concurrent_state["slices"] = self._connector_state_converter.merge_intervals( 392 self._concurrent_state["slices"] 393 ) 394 395 def _extract_from_slice(self, partition: Partition, key: str) -> CursorValueType: 396 try: 397 _slice = partition.to_slice() 398 if not _slice: 399 raise KeyError(f"Could not find key `{key}` in empty slice") 400 return self._connector_state_converter.parse_value(_slice[key]) # type: ignore # we expect the devs to specify a key that would return a CursorValueType 401 except KeyError as exception: 402 raise KeyError( 403 f"Partition is expected to have key `{key}` but could not be found" 404 ) from exception 405 406 def ensure_at_least_one_state_emitted(self) -> None: 407 """ 408 The platform expect to have at least one state message on successful syncs. Hence, whatever happens, we expect this method to be 409 called. 410 """ 411 self._emit_state_message() 412 413 def stream_slices(self) -> Iterable[StreamSlice]: 414 """ 415 Generating slices based on a few parameters: 416 * lookback_window: Buffer to remove from END_KEY of the highest slice 417 * slice_range: Max difference between two slices. If the difference between two slices is greater, multiple slices will be created 418 * start: `_split_per_slice_range` will clip any value to `self._start which means that: 419 * if upper is less than self._start, no slices will be generated 420 * if lower is less than self._start, self._start will be used as the lower boundary (lookback_window will not be considered in that case) 421 422 Note that the slices will overlap at their boundaries. We therefore expect to have at least the lower or the upper boundary to be 423 inclusive in the API that is queried. 424 """ 425 self._merge_partitions() 426 427 if self._start is not None and self._is_start_before_first_slice(): 428 yield from self._split_per_slice_range( 429 self._start, 430 self._concurrent_state["slices"][0][self._connector_state_converter.START_KEY], 431 False, 432 ) 433 434 if len(self._concurrent_state["slices"]) == 1: 435 yield from self._split_per_slice_range( 436 self._calculate_lower_boundary_of_last_slice( 437 self._concurrent_state["slices"][0][self._connector_state_converter.END_KEY] 438 ), 439 self._end_provider(), 440 True, 441 ) 442 elif len(self._concurrent_state["slices"]) > 1: 443 for i in range(len(self._concurrent_state["slices"]) - 1): 444 if self._cursor_granularity: 445 yield from self._split_per_slice_range( 446 self._concurrent_state["slices"][i][self._connector_state_converter.END_KEY] 447 + self._cursor_granularity, 448 self._concurrent_state["slices"][i + 1][ 449 self._connector_state_converter.START_KEY 450 ], 451 False, 452 ) 453 else: 454 yield from self._split_per_slice_range( 455 self._concurrent_state["slices"][i][ 456 self._connector_state_converter.END_KEY 457 ], 458 self._concurrent_state["slices"][i + 1][ 459 self._connector_state_converter.START_KEY 460 ], 461 False, 462 ) 463 yield from self._split_per_slice_range( 464 self._calculate_lower_boundary_of_last_slice( 465 self._concurrent_state["slices"][-1][self._connector_state_converter.END_KEY] 466 ), 467 self._end_provider(), 468 True, 469 ) 470 else: 471 raise ValueError("Expected at least one slice") 472 473 def _is_start_before_first_slice(self) -> bool: 474 return ( 475 self._start is not None 476 and self._start 477 < self._concurrent_state["slices"][0][self._connector_state_converter.START_KEY] 478 ) 479 480 def _calculate_lower_boundary_of_last_slice( 481 self, lower_boundary: CursorValueType 482 ) -> CursorValueType: 483 if self._lookback_window: 484 return lower_boundary - self._lookback_window 485 return lower_boundary 486 487 def _split_per_slice_range( 488 self, lower: CursorValueType, upper: CursorValueType, upper_is_end: bool 489 ) -> Iterable[StreamSlice]: 490 if lower > upper or (lower == upper and not self._cursor_granularity): 491 return 492 493 if self._start and upper < self._start: 494 return 495 496 lower = max(lower, self._start) if self._start else lower 497 # Clamp first, else a step shorter than the clamping period collapses the first window 498 lower = self._clamping_strategy.clamp(lower) 499 # With a granularity the last window includes upper, so lower == upper is one unit to read, 500 # unless the API rejects start == end. In a gap, the next slice already reads upper. 501 if lower > upper or (lower == upper and (self._is_compare_strictly or not upper_is_end)): 502 return 503 if not self._slice_range or self._evaluate_upper_safely(lower, self._slice_range) >= upper: 504 # With a granularity upper is the last unit to read: clamping it would read past the end 505 clamped_upper = ( 506 upper 507 if upper_is_end and self._cursor_granularity 508 else self._clamping_strategy.clamp(upper) 509 ) 510 start_value, end_value = ( 511 (lower, clamped_upper - self._cursor_granularity) 512 if self._cursor_granularity and not upper_is_end 513 else (lower, clamped_upper) 514 ) 515 yield StreamSlice( 516 partition={}, 517 cursor_slice={ 518 self._slice_boundary_fields_wrapper[ 519 self._START_BOUNDARY 520 ]: self._connector_state_converter.output_format(start_value), 521 self._slice_boundary_fields_wrapper[ 522 self._END_BOUNDARY 523 ]: self._connector_state_converter.output_format(end_value), 524 }, 525 ) 526 else: 527 stop_processing = False 528 current_lower_boundary = lower 529 while not stop_processing: 530 current_upper_boundary = min( 531 self._evaluate_upper_safely(current_lower_boundary, self._slice_range), upper 532 ) 533 clamped_upper = ( 534 self._clamping_strategy.clamp(current_upper_boundary) 535 if current_upper_boundary != upper 536 else current_upper_boundary 537 ) 538 # A boundary clamped onto upper makes this the last window, else upper is dropped 539 has_reached_upper_boundary = ( 540 current_upper_boundary >= upper or clamped_upper == upper 541 ) 542 clamped_lower = self._clamping_strategy.clamp(current_lower_boundary) 543 if clamped_lower >= clamped_upper: 544 # clamping collapsed both values which means that it is time to stop processing 545 # FIXME should this be replace by proper end_provider 546 break 547 start_value, end_value = ( 548 (clamped_lower, clamped_upper - self._cursor_granularity) 549 if self._cursor_granularity 550 and (not upper_is_end or not has_reached_upper_boundary) 551 else (clamped_lower, clamped_upper) 552 ) 553 yield StreamSlice( 554 partition={}, 555 cursor_slice={ 556 self._slice_boundary_fields_wrapper[ 557 self._START_BOUNDARY 558 ]: self._connector_state_converter.output_format(start_value), 559 self._slice_boundary_fields_wrapper[ 560 self._END_BOUNDARY 561 ]: self._connector_state_converter.output_format(end_value), 562 }, 563 ) 564 current_lower_boundary = clamped_upper 565 if has_reached_upper_boundary: 566 stop_processing = True 567 568 def _evaluate_upper_safely(self, lower: CursorValueType, step: GapType) -> CursorValueType: 569 """ 570 Given that we set the default step at datetime.timedelta.max, we will generate an OverflowError when evaluating the next start_date 571 This method assumes that users would never enter a step that would generate an overflow. Given that would be the case, the code 572 would have broken anyway. 573 """ 574 try: 575 return lower + step 576 except OverflowError: 577 return self._end_provider() 578 579 def should_be_synced(self, record: Record) -> bool: 580 """ 581 Determines if a record should be synced based on its cursor value. 582 :param record: The record to evaluate 583 584 :return: True if the record's cursor value falls within the sync boundaries 585 """ 586 try: 587 record_cursor_value: CursorValueType = self._extract_cursor_value(record) 588 except ValueError: 589 self._log_for_record_without_cursor_value() 590 return True 591 return self._lowest_cursor_value_to_sync() <= record_cursor_value <= self._end_provider() 592 593 def _lowest_cursor_value_to_sync(self) -> CursorValueType: 594 # Keep the records the lookback window re-reads, never below the configured start. If the state is below the 595 # configured start, keep records from the state rather than raising the floor to the configured start. 596 if not self._lookback_window: 597 return self.start 598 try: 599 lower = self.start - self._lookback_window 600 except OverflowError: 601 return self.start 602 return max(lower, min(self.start, self._start)) if self._start else lower 603 604 def _log_for_record_without_cursor_value(self) -> None: 605 if not self._should_be_synced_logger_triggered: 606 LOGGER.warning( 607 f"Could not find cursor field `{self.cursor_field.cursor_field_key}` in record for stream {self._stream_name}. The incremental sync will assume it needs to be synced" 608 ) 609 self._should_be_synced_logger_triggered = True 610 611 def reduce_slice_range(self, stream_slice: StreamSlice) -> StreamSlice: 612 # In theory, we might be more flexible here meaning that it doesn't need to be in ascending order but it just 613 # needs to be ordered. For now though, we will only support ascending order. 614 if not self._is_ascending_order: 615 LOGGER.warning( 616 "Attempting to reduce slice while records are not returned in incremental order might lead to missing records" 617 ) 618 619 if stream_slice in self._most_recent_cursor_value_per_partition: 620 return StreamSlice( 621 partition=stream_slice.partition, 622 cursor_slice={ 623 self._slice_boundary_fields_wrapper[ 624 self._START_BOUNDARY 625 ]: self._connector_state_converter.output_format( 626 self._most_recent_cursor_value_per_partition[stream_slice] 627 ), 628 self._slice_boundary_fields_wrapper[ 629 self._END_BOUNDARY 630 ]: stream_slice.cursor_slice[ 631 self._slice_boundary_fields_wrapper[self._END_BOUNDARY] 632 ], 633 }, 634 extra_fields=stream_slice.extra_fields, 635 ) 636 else: 637 return stream_slice 638 639 def split_request_window( 640 self, stream_slice: StreamSlice, min_split_window: Optional[datetime.timedelta] = None 641 ) -> Optional[List[StreamSlice]]: 642 """ 643 Split `stream_slice` in half along this cursor's own boundary fields, granularity, and output format. 644 645 Returns `None` when the slice cannot be split any further: either it spans at most one 646 `cursor_granularity` unit already (the floor), it is already at or below `min_split_window` (a 647 connector-configured floor independent of and typically looser than `cursor_granularity`), the 648 boundaries are reversed, or no `cursor_granularity` was configured at all (there is then no way to know 649 the smallest addressable unit, or how to keep two children from overlapping at their shared edge). 650 Raises `AirbyteTracedException` instead if a boundary is missing or unparseable: that is a bug in how 651 the slice was produced, not the API rejecting a window, so it must not be reported as one. This is the 652 implementation `SimpleRetriever.request_window_splitter` binds to (see 653 `airbyte_cdk.sources.declarative.retrievers.simple_retriever`) that reuses the same 654 parsing/formatting/comparison operations `_split_per_slice_range` already relies on, so it works for 655 any `CursorValueType`/`GapType` pair this cursor was built with (datetime/timedelta, or int/int), 656 without a second, duplicate implementation of that logic living outside the cursor. 657 """ 658 if not self._cursor_granularity: 659 return None 660 661 start_field, end_field = self._slice_boundary_fields_wrapper 662 try: 663 start_value = self._connector_state_converter.parse_value( 664 stream_slice.cursor_slice[start_field] 665 ) 666 end_value = self._connector_state_converter.parse_value( 667 stream_slice.cursor_slice[end_field] 668 ) 669 except (KeyError, ValueError, TypeError) as exception: 670 # A missing or unparseable boundary means this slice was produced (or passed in) wrong - a bug in 671 # slice generation, not something the API did. Raising here, rather than returning `None` (which 672 # `SimpleRetriever` reports as "the API kept rejecting..."), keeps that distinction visible instead 673 # of blaming the API for our own state. 674 raise AirbyteTracedException( 675 internal_message=( 676 f"Cannot split {stream_slice}: boundary field {start_field!r} or {end_field!r} is missing " 677 f"or not parseable: {exception}" 678 ), 679 message=( 680 f"Stream {self._stream_name} produced a request window with a missing or invalid " 681 f"{start_field!r}/{end_field!r} boundary and could not split it further." 682 ), 683 failure_type=FailureType.system_error, 684 ) from exception 685 686 if start_value >= end_value: 687 # Reversed or degenerate boundaries: nothing safe to split. 688 return None 689 690 span = end_value - start_value # type: ignore[operator] # concretely a GapType (timedelta/int) at runtime 691 if min_split_window is not None and span <= min_split_window: # type: ignore[operator] 692 return None 693 694 total_units = span // self._cursor_granularity + 1 # type: ignore[operator] # inclusive count of granularity units 695 if total_units <= 1: 696 # Already at the minimum granularity and still rejected; nothing left to split. 697 return None 698 699 half_units = total_units // 2 700 first_end = ( 701 start_value + (half_units * self._cursor_granularity) - self._cursor_granularity # type: ignore[operator] 702 ) 703 second_start = first_end + self._cursor_granularity 704 705 if not (start_value <= first_end < second_start <= end_value): 706 # No-progress guard, independent of the granularity check above: refuse to return children that 707 # are not strictly ordered and non-degenerate, rather than risk an identical or overlapping split. 708 return None 709 710 return [ 711 StreamSlice( 712 partition=stream_slice.partition, 713 cursor_slice={ 714 start_field: self._connector_state_converter.output_format(start_value), 715 end_field: self._connector_state_converter.output_format(first_end), 716 }, 717 extra_fields=stream_slice.extra_fields, 718 ), 719 StreamSlice( 720 partition=stream_slice.partition, 721 cursor_slice={ 722 start_field: self._connector_state_converter.output_format(second_start), 723 end_field: self._connector_state_converter.output_format(end_value), 724 }, 725 extra_fields=stream_slice.extra_fields, 726 ), 727 ] 728 729 def get_cursor_datetime_from_state( 730 self, stream_state: Mapping[str, Any] 731 ) -> datetime.datetime | None: 732 """Extract and parse the cursor datetime from the given stream state. 733 734 For concurrent cursors, the state can be in two formats: 735 1. Sequential/legacy format: {cursor_field: cursor_value} 736 2. Concurrent format: {state_type: "date-range", slices: [...]} 737 738 Returns the cursor datetime if present and parseable, otherwise returns None. 739 """ 740 # Check if state is in concurrent format (need to convert to dict for type compatibility) 741 mutable_state: MutableMapping[str, Any] = dict(stream_state) 742 if self._connector_state_converter.is_state_message_compatible(mutable_state): 743 slices = stream_state.get("slices", []) 744 if not slices: 745 return None 746 # Get the most recent cursor value from the first slice (after merging) 747 first_slice = slices[0] 748 cursor_value = first_slice.get( 749 self._connector_state_converter.MOST_RECENT_RECORD_KEY 750 ) or first_slice.get(self._connector_state_converter.END_KEY) 751 if not cursor_value: 752 return None 753 try: 754 parsed_value = self._connector_state_converter.parse_value(cursor_value) 755 if isinstance(parsed_value, datetime.datetime): 756 return parsed_value 757 return None 758 except (ValueError, TypeError): 759 return None 760 761 # Sequential/legacy format: {cursor_field: cursor_value} 762 cursor_value = stream_state.get(self._cursor_field.cursor_field_key) 763 if not cursor_value: 764 return None 765 try: 766 parsed_value = self._connector_state_converter.parse_value(cursor_value) 767 if isinstance(parsed_value, datetime.datetime): 768 return parsed_value 769 return None 770 except (ValueError, TypeError): 771 return None
Slices the stream into chunks that can be fetched independently. Slices enable state checkpointing and data retrieval parallelization.
202 def __init__( 203 self, 204 stream_name: str, 205 stream_namespace: Optional[str], 206 stream_state: Any, 207 message_repository: MessageRepository, 208 connector_state_manager: ConnectorStateManager, 209 connector_state_converter: AbstractStreamStateConverter, 210 cursor_field: CursorField, 211 slice_boundary_fields: Optional[Tuple[str, str]], 212 start: Optional[CursorValueType], 213 end_provider: Callable[[], CursorValueType], 214 lookback_window: Optional[GapType] = None, 215 slice_range: Optional[GapType] = None, 216 cursor_granularity: Optional[GapType] = None, 217 clamping_strategy: ClampingStrategy = NoClamping(), 218 is_compare_strictly: bool = False, 219 ) -> None: 220 self._stream_name = stream_name 221 self._stream_namespace = stream_namespace 222 self._message_repository = message_repository 223 self._connector_state_converter = connector_state_converter 224 self._connector_state_manager = connector_state_manager 225 self._cursor_field = cursor_field 226 # To see some example where the slice boundaries might not be defined, check https://github.com/airbytehq/airbyte/blob/1ce84d6396e446e1ac2377362446e3fb94509461/airbyte-integrations/connectors/source-stripe/source_stripe/streams.py#L363-L379 227 self._slice_boundary_fields = slice_boundary_fields 228 self._start = start 229 self._end_provider = end_provider 230 self.start, self._concurrent_state = self._get_concurrent_state(stream_state) 231 self._lookback_window = lookback_window 232 self._slice_range = slice_range 233 self._most_recent_cursor_value_per_partition: MutableMapping[ 234 Union[StreamSlice, Mapping[str, Any], None], Any 235 ] = {} 236 self._has_closed_at_least_one_slice = False 237 self._cursor_granularity = cursor_granularity 238 # Flag to track if the logger has been triggered (per stream) 239 self._should_be_synced_logger_triggered = False 240 self._clamping_strategy = clamping_strategy 241 self._is_compare_strictly = is_compare_strictly 242 self._is_ascending_order = True 243 244 # A lock is required when closing a partition because updating the cursor's concurrent_state is 245 # not thread safe. When multiple partitions are being closed by the cursor at the same time, it is 246 # possible for one partition to update concurrent_state after a second partition has already read 247 # the previous state. This can lead to the second partition overwriting the previous one's state. 248 self._lock = threading.Lock()
183 def copy_without_state(self) -> "ConcurrentCursor": 184 return self.__class__( 185 stream_name=self._stream_name, 186 stream_namespace=self._stream_namespace, 187 stream_state={}, 188 message_repository=NoopMessageRepository(), 189 connector_state_manager=ConnectorStateManager(), 190 connector_state_converter=self._connector_state_converter, 191 cursor_field=self._cursor_field, 192 slice_boundary_fields=self._slice_boundary_fields, 193 start=self._start, 194 end_provider=self._end_provider, 195 lookback_window=self._lookback_window, 196 slice_range=self._slice_range, 197 cursor_granularity=self._cursor_granularity, 198 clamping_strategy=self._clamping_strategy, 199 is_compare_strictly=self._is_compare_strictly, 200 )
297 def observe(self, record: Record) -> None: 298 # Because observe writes to the most_recent_cursor_value_per_partition mapping, 299 # it is not thread-safe. However, this shouldn't lead to concurrency issues because 300 # observe() is only invoked by PartitionReader.process_partition(). Since the map is 301 # broken down according to partition, concurrent threads processing only read/write 302 # from different keys which avoids any conflicts. 303 # 304 # If we were to add thread safety, we should implement a lock per-partition 305 # which is instantiated during stream_slices() 306 most_recent_cursor_value = self._most_recent_cursor_value_per_partition.get( 307 record.associated_slice 308 ) 309 try: 310 cursor_value = self._extract_cursor_value(record) 311 312 if most_recent_cursor_value is None or most_recent_cursor_value < cursor_value: 313 self._most_recent_cursor_value_per_partition[record.associated_slice] = cursor_value 314 elif most_recent_cursor_value > cursor_value: 315 self._is_ascending_order = False 316 except ValueError: 317 self._log_for_record_without_cursor_value()
Indicate to the cursor that the record has been emitted
322 def close_partition(self, partition: Partition) -> None: 323 with self._lock: 324 slice_count_before = len(self._concurrent_state.get("slices", [])) 325 self._add_slice_to_state(partition) 326 if slice_count_before < len( 327 self._concurrent_state["slices"] 328 ): # only emit if at least one slice has been processed 329 self._merge_partitions() 330 self._emit_state_message() 331 self._has_closed_at_least_one_slice = True
Indicate to the cursor that the partition has been successfully processed
406 def ensure_at_least_one_state_emitted(self) -> None: 407 """ 408 The platform expect to have at least one state message on successful syncs. Hence, whatever happens, we expect this method to be 409 called. 410 """ 411 self._emit_state_message()
The platform expect to have at least one state message on successful syncs. Hence, whatever happens, we expect this method to be called.
413 def stream_slices(self) -> Iterable[StreamSlice]: 414 """ 415 Generating slices based on a few parameters: 416 * lookback_window: Buffer to remove from END_KEY of the highest slice 417 * slice_range: Max difference between two slices. If the difference between two slices is greater, multiple slices will be created 418 * start: `_split_per_slice_range` will clip any value to `self._start which means that: 419 * if upper is less than self._start, no slices will be generated 420 * if lower is less than self._start, self._start will be used as the lower boundary (lookback_window will not be considered in that case) 421 422 Note that the slices will overlap at their boundaries. We therefore expect to have at least the lower or the upper boundary to be 423 inclusive in the API that is queried. 424 """ 425 self._merge_partitions() 426 427 if self._start is not None and self._is_start_before_first_slice(): 428 yield from self._split_per_slice_range( 429 self._start, 430 self._concurrent_state["slices"][0][self._connector_state_converter.START_KEY], 431 False, 432 ) 433 434 if len(self._concurrent_state["slices"]) == 1: 435 yield from self._split_per_slice_range( 436 self._calculate_lower_boundary_of_last_slice( 437 self._concurrent_state["slices"][0][self._connector_state_converter.END_KEY] 438 ), 439 self._end_provider(), 440 True, 441 ) 442 elif len(self._concurrent_state["slices"]) > 1: 443 for i in range(len(self._concurrent_state["slices"]) - 1): 444 if self._cursor_granularity: 445 yield from self._split_per_slice_range( 446 self._concurrent_state["slices"][i][self._connector_state_converter.END_KEY] 447 + self._cursor_granularity, 448 self._concurrent_state["slices"][i + 1][ 449 self._connector_state_converter.START_KEY 450 ], 451 False, 452 ) 453 else: 454 yield from self._split_per_slice_range( 455 self._concurrent_state["slices"][i][ 456 self._connector_state_converter.END_KEY 457 ], 458 self._concurrent_state["slices"][i + 1][ 459 self._connector_state_converter.START_KEY 460 ], 461 False, 462 ) 463 yield from self._split_per_slice_range( 464 self._calculate_lower_boundary_of_last_slice( 465 self._concurrent_state["slices"][-1][self._connector_state_converter.END_KEY] 466 ), 467 self._end_provider(), 468 True, 469 ) 470 else: 471 raise ValueError("Expected at least one slice")
Generating slices based on a few parameters:
- lookback_window: Buffer to remove from END_KEY of the highest slice
- slice_range: Max difference between two slices. If the difference between two slices is greater, multiple slices will be created
- start:
_split_per_slice_rangewill clip any value to `self._start which means that:- if upper is less than self._start, no slices will be generated
- if lower is less than self._start, self._start will be used as the lower boundary (lookback_window will not be considered in that case)
Note that the slices will overlap at their boundaries. We therefore expect to have at least the lower or the upper boundary to be inclusive in the API that is queried.
579 def should_be_synced(self, record: Record) -> bool: 580 """ 581 Determines if a record should be synced based on its cursor value. 582 :param record: The record to evaluate 583 584 :return: True if the record's cursor value falls within the sync boundaries 585 """ 586 try: 587 record_cursor_value: CursorValueType = self._extract_cursor_value(record) 588 except ValueError: 589 self._log_for_record_without_cursor_value() 590 return True 591 return self._lowest_cursor_value_to_sync() <= record_cursor_value <= self._end_provider()
Determines if a record should be synced based on its cursor value.
Parameters
- record: The record to evaluate
Returns
True if the record's cursor value falls within the sync boundaries
611 def reduce_slice_range(self, stream_slice: StreamSlice) -> StreamSlice: 612 # In theory, we might be more flexible here meaning that it doesn't need to be in ascending order but it just 613 # needs to be ordered. For now though, we will only support ascending order. 614 if not self._is_ascending_order: 615 LOGGER.warning( 616 "Attempting to reduce slice while records are not returned in incremental order might lead to missing records" 617 ) 618 619 if stream_slice in self._most_recent_cursor_value_per_partition: 620 return StreamSlice( 621 partition=stream_slice.partition, 622 cursor_slice={ 623 self._slice_boundary_fields_wrapper[ 624 self._START_BOUNDARY 625 ]: self._connector_state_converter.output_format( 626 self._most_recent_cursor_value_per_partition[stream_slice] 627 ), 628 self._slice_boundary_fields_wrapper[ 629 self._END_BOUNDARY 630 ]: stream_slice.cursor_slice[ 631 self._slice_boundary_fields_wrapper[self._END_BOUNDARY] 632 ], 633 }, 634 extra_fields=stream_slice.extra_fields, 635 ) 636 else: 637 return stream_slice
639 def split_request_window( 640 self, stream_slice: StreamSlice, min_split_window: Optional[datetime.timedelta] = None 641 ) -> Optional[List[StreamSlice]]: 642 """ 643 Split `stream_slice` in half along this cursor's own boundary fields, granularity, and output format. 644 645 Returns `None` when the slice cannot be split any further: either it spans at most one 646 `cursor_granularity` unit already (the floor), it is already at or below `min_split_window` (a 647 connector-configured floor independent of and typically looser than `cursor_granularity`), the 648 boundaries are reversed, or no `cursor_granularity` was configured at all (there is then no way to know 649 the smallest addressable unit, or how to keep two children from overlapping at their shared edge). 650 Raises `AirbyteTracedException` instead if a boundary is missing or unparseable: that is a bug in how 651 the slice was produced, not the API rejecting a window, so it must not be reported as one. This is the 652 implementation `SimpleRetriever.request_window_splitter` binds to (see 653 `airbyte_cdk.sources.declarative.retrievers.simple_retriever`) that reuses the same 654 parsing/formatting/comparison operations `_split_per_slice_range` already relies on, so it works for 655 any `CursorValueType`/`GapType` pair this cursor was built with (datetime/timedelta, or int/int), 656 without a second, duplicate implementation of that logic living outside the cursor. 657 """ 658 if not self._cursor_granularity: 659 return None 660 661 start_field, end_field = self._slice_boundary_fields_wrapper 662 try: 663 start_value = self._connector_state_converter.parse_value( 664 stream_slice.cursor_slice[start_field] 665 ) 666 end_value = self._connector_state_converter.parse_value( 667 stream_slice.cursor_slice[end_field] 668 ) 669 except (KeyError, ValueError, TypeError) as exception: 670 # A missing or unparseable boundary means this slice was produced (or passed in) wrong - a bug in 671 # slice generation, not something the API did. Raising here, rather than returning `None` (which 672 # `SimpleRetriever` reports as "the API kept rejecting..."), keeps that distinction visible instead 673 # of blaming the API for our own state. 674 raise AirbyteTracedException( 675 internal_message=( 676 f"Cannot split {stream_slice}: boundary field {start_field!r} or {end_field!r} is missing " 677 f"or not parseable: {exception}" 678 ), 679 message=( 680 f"Stream {self._stream_name} produced a request window with a missing or invalid " 681 f"{start_field!r}/{end_field!r} boundary and could not split it further." 682 ), 683 failure_type=FailureType.system_error, 684 ) from exception 685 686 if start_value >= end_value: 687 # Reversed or degenerate boundaries: nothing safe to split. 688 return None 689 690 span = end_value - start_value # type: ignore[operator] # concretely a GapType (timedelta/int) at runtime 691 if min_split_window is not None and span <= min_split_window: # type: ignore[operator] 692 return None 693 694 total_units = span // self._cursor_granularity + 1 # type: ignore[operator] # inclusive count of granularity units 695 if total_units <= 1: 696 # Already at the minimum granularity and still rejected; nothing left to split. 697 return None 698 699 half_units = total_units // 2 700 first_end = ( 701 start_value + (half_units * self._cursor_granularity) - self._cursor_granularity # type: ignore[operator] 702 ) 703 second_start = first_end + self._cursor_granularity 704 705 if not (start_value <= first_end < second_start <= end_value): 706 # No-progress guard, independent of the granularity check above: refuse to return children that 707 # are not strictly ordered and non-degenerate, rather than risk an identical or overlapping split. 708 return None 709 710 return [ 711 StreamSlice( 712 partition=stream_slice.partition, 713 cursor_slice={ 714 start_field: self._connector_state_converter.output_format(start_value), 715 end_field: self._connector_state_converter.output_format(first_end), 716 }, 717 extra_fields=stream_slice.extra_fields, 718 ), 719 StreamSlice( 720 partition=stream_slice.partition, 721 cursor_slice={ 722 start_field: self._connector_state_converter.output_format(second_start), 723 end_field: self._connector_state_converter.output_format(end_value), 724 }, 725 extra_fields=stream_slice.extra_fields, 726 ), 727 ]
Split stream_slice in half along this cursor's own boundary fields, granularity, and output format.
Returns None when the slice cannot be split any further: either it spans at most one
cursor_granularity unit already (the floor), it is already at or below min_split_window (a
connector-configured floor independent of and typically looser than cursor_granularity), the
boundaries are reversed, or no cursor_granularity was configured at all (there is then no way to know
the smallest addressable unit, or how to keep two children from overlapping at their shared edge).
Raises AirbyteTracedException instead if a boundary is missing or unparseable: that is a bug in how
the slice was produced, not the API rejecting a window, so it must not be reported as one. This is the
implementation SimpleRetriever.request_window_splitter binds to (see
airbyte_cdk.sources.declarative.retrievers.simple_retriever) that reuses the same
parsing/formatting/comparison operations _split_per_slice_range already relies on, so it works for
any CursorValueType/GapType pair this cursor was built with (datetime/timedelta, or int/int),
without a second, duplicate implementation of that logic living outside the cursor.
729 def get_cursor_datetime_from_state( 730 self, stream_state: Mapping[str, Any] 731 ) -> datetime.datetime | None: 732 """Extract and parse the cursor datetime from the given stream state. 733 734 For concurrent cursors, the state can be in two formats: 735 1. Sequential/legacy format: {cursor_field: cursor_value} 736 2. Concurrent format: {state_type: "date-range", slices: [...]} 737 738 Returns the cursor datetime if present and parseable, otherwise returns None. 739 """ 740 # Check if state is in concurrent format (need to convert to dict for type compatibility) 741 mutable_state: MutableMapping[str, Any] = dict(stream_state) 742 if self._connector_state_converter.is_state_message_compatible(mutable_state): 743 slices = stream_state.get("slices", []) 744 if not slices: 745 return None 746 # Get the most recent cursor value from the first slice (after merging) 747 first_slice = slices[0] 748 cursor_value = first_slice.get( 749 self._connector_state_converter.MOST_RECENT_RECORD_KEY 750 ) or first_slice.get(self._connector_state_converter.END_KEY) 751 if not cursor_value: 752 return None 753 try: 754 parsed_value = self._connector_state_converter.parse_value(cursor_value) 755 if isinstance(parsed_value, datetime.datetime): 756 return parsed_value 757 return None 758 except (ValueError, TypeError): 759 return None 760 761 # Sequential/legacy format: {cursor_field: cursor_value} 762 cursor_value = stream_state.get(self._cursor_field.cursor_field_key) 763 if not cursor_value: 764 return None 765 try: 766 parsed_value = self._connector_state_converter.parse_value(cursor_value) 767 if isinstance(parsed_value, datetime.datetime): 768 return parsed_value 769 return None 770 except (ValueError, TypeError): 771 return None
Extract and parse the cursor datetime from the given stream state.
For concurrent cursors, the state can be in two formats:
- Sequential/legacy format: {cursor_field: cursor_value}
- Concurrent format: {state_type: "date-range", slices: [...]}
Returns the cursor datetime if present and parseable, otherwise returns None.