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
LOGGER = <Logger airbyte (INFO)>
class CursorField:
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
CursorField( cursor_field_key: str, supports_catalog_defined_cursor_field: bool = False)
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
cursor_field_key
supports_catalog_defined_cursor_field
def extract_value(self, record: airbyte_cdk.Record) -> Any:
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.

state: MutableMapping[str, Any]
59    @property
60    @abstractmethod
61    def state(self) -> MutableMapping[str, Any]: ...
@abstractmethod
def observe(self, record: airbyte_cdk.Record) -> None:
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

@abstractmethod
def close_partition( self, partition: airbyte_cdk.sources.streams.concurrent.partitions.partition.Partition) -> None:
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

@abstractmethod
def ensure_at_least_one_state_emitted(self) -> None:
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.

@abstractmethod
def should_be_synced(self, record: airbyte_cdk.Record) -> bool:
85    @abstractmethod
86    def should_be_synced(self, record: Record) -> bool:
87        pass
def stream_slices(self) -> Iterable[airbyte_cdk.StreamSlice]:
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.

def get_cursor_datetime_from_state(self, stream_state: Mapping[str, Any]) -> datetime.datetime | None:
 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.

class FinalStateCursor(Cursor):
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.

FinalStateCursor( stream_name: str, stream_namespace: Optional[str], message_repository: airbyte_cdk.MessageRepository)
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
state: MutableMapping[str, Any]
136    @property
137    def state(self) -> MutableMapping[str, Any]:
138        return {NO_CURSOR_STATE_KEY: True}
def observe(self, record: airbyte_cdk.Record) -> None:
140    def observe(self, record: Record) -> None:
141        pass

Indicate to the cursor that the record has been emitted

def close_partition( self, partition: airbyte_cdk.sources.streams.concurrent.partitions.partition.Partition) -> None:
143    def close_partition(self, partition: Partition) -> None:
144        pass

Indicate to the cursor that the partition has been successfully processed

def ensure_at_least_one_state_emitted(self) -> None:
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

def should_be_synced(self, record: airbyte_cdk.Record) -> bool:
159    def should_be_synced(self, record: Record) -> bool:
160        return True
def get_cursor_datetime_from_state(self, stream_state: Mapping[str, Any]) -> datetime.datetime | None:
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
Cursor
stream_slices
class ConcurrentCursor(Cursor):
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.

ConcurrentCursor( stream_name: str, stream_namespace: Optional[str], stream_state: Any, message_repository: airbyte_cdk.MessageRepository, connector_state_manager: airbyte_cdk.ConnectorStateManager, connector_state_converter: airbyte_cdk.sources.streams.concurrent.state_converters.abstract_stream_state_converter.AbstractStreamStateConverter, cursor_field: CursorField, slice_boundary_fields: Optional[Tuple[str, str]], start: Optional[airbyte_cdk.sources.streams.concurrent.cursor_types.CursorValueType], end_provider: Callable[[], airbyte_cdk.sources.streams.concurrent.cursor_types.CursorValueType], lookback_window: Optional[airbyte_cdk.sources.streams.concurrent.cursor_types.GapType] = None, slice_range: Optional[airbyte_cdk.sources.streams.concurrent.cursor_types.GapType] = None, cursor_granularity: Optional[airbyte_cdk.sources.streams.concurrent.cursor_types.GapType] = None, clamping_strategy: airbyte_cdk.sources.streams.concurrent.clamping.ClampingStrategy = <airbyte_cdk.sources.streams.concurrent.clamping.NoClamping object>, is_compare_strictly: bool = False)
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()
def copy_without_state(self) -> ConcurrentCursor:
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        )
state: MutableMapping[str, Any]
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        )
cursor_field: CursorField
256    @property
257    def cursor_field(self) -> CursorField:
258        return self._cursor_field
def observe(self, record: airbyte_cdk.Record) -> None:
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

def close_partition( self, partition: airbyte_cdk.sources.streams.concurrent.partitions.partition.Partition) -> None:
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

def ensure_at_least_one_state_emitted(self) -> None:
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.

def stream_slices(self) -> Iterable[airbyte_cdk.StreamSlice]:
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_range will 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.

def should_be_synced(self, record: airbyte_cdk.Record) -> bool:
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

def reduce_slice_range( self, stream_slice: airbyte_cdk.StreamSlice) -> airbyte_cdk.StreamSlice:
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
def split_request_window( self, stream_slice: airbyte_cdk.StreamSlice, min_split_window: Optional[datetime.timedelta] = None) -> Optional[List[airbyte_cdk.StreamSlice]]:
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.

def get_cursor_datetime_from_state(self, stream_state: Mapping[str, Any]) -> datetime.datetime | None:
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:

  1. Sequential/legacy format: {cursor_field: cursor_value}
  2. Concurrent format: {state_type: "date-range", slices: [...]}

Returns the cursor datetime if present and parseable, otherwise returns None.