Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
98a4ee5
Add CDM Timeseries Upload Queue
devendra-lohar May 8, 2025
03907c7
codestyle check fixes
devendra-lohar May 9, 2025
9498de6
test: testing
toondaey May 9, 2025
88bc282
test: print test logs
toondaey May 9, 2025
3a0c592
test: remove changes
toondaey May 9, 2025
399c989
Merge branch 'master' into DOG-5255-CDM-Timeseries-db-extractor
devendra-lohar May 13, 2025
6d1a1c9
Add tests for CDM Timeseries upload queue
devendra-lohar May 14, 2025
184e47b
adding common datapoints sanitize method
devendra-lohar May 15, 2025
ee2e2cc
review comment fixes
devendra-lohar May 19, 2025
284ad8a
Merge branch 'master' into DOG-5255-CDM-Timeseries-db-extractor
toondaey May 20, 2025
726a0aa
Add BaseTimeSeriesUploadQueue class
devendra-lohar May 21, 2025
77e8f3a
BaseTimeSeriesUploadQueue changes
devendra-lohar May 23, 2025
728102f
Add CDMTimeSeriesUploadQueue changes to uploader_extractor
devendra-lohar May 23, 2025
2de87f9
Refactor: unify __enter__ in the Base class
devendra-lohar May 23, 2025
8443844
Regular review changes
devendra-lohar May 29, 2025
8acafd7
removing unneccesary omment
devendra-lohar May 30, 2025
be6f791
Align cdm timeseries creation logic with legacy
devendra-lohar Jun 4, 2025
94f7738
[Modify] Align consistency with legacy missing factory
devendra-lohar Jun 9, 2025
50576f4
Merge branch 'master' into DOG-5255-CDM-Timeseries-db-extractor
devendra-lohar Jun 9, 2025
06eb57e
ruff formatting changes
devendra-lohar Jun 11, 2025
ed59c8b
[modify] regular formatting changes
devendra-lohar Jun 11, 2025
ab76970
ruff formatting change
devendra-lohar Jun 11, 2025
8432554
Merge branch 'master' into DOG-5255-CDM-Timeseries-db-extractor
devendra-lohar Jun 12, 2025
dea4ef4
fixing docstring
devendra-lohar Jun 12, 2025
ecdade5
Merge branch 'master' into DOG-5255-CDM-Timeseries-db-extractor
devendra-lohar Jun 24, 2025
8e63e36
fix type for insert datapoints api in metrics.py
devendra-lohar Jun 24, 2025
a709828
add more robust testcases to cover edge cases
devendra-lohar Jun 24, 2025
f024e28
Merge branch 'master' into DOG-5255-CDM-Timeseries-db-extractor
devendra-lohar Jun 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 86 additions & 19 deletions cognite/extractorutils/uploader/time_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from collections.abc import Callable
from datetime import datetime
from types import TracebackType
from typing import Any, TypeVar
from typing import Any, Generic, TypeVar

from cognite.client import CogniteClient
from cognite.client.data_classes import (
Expand Down Expand Up @@ -62,6 +62,7 @@
DataPointList = list[DataPoint]

TQueue = TypeVar("TQueue", bound="BaseTimeSeriesUploadQueue")
IdType = TypeVar("IdType", EitherId, NodeId)


def default_time_series_factory(external_id: str, datapoints: DataPointList) -> TimeSeries:
Expand All @@ -83,7 +84,13 @@ def default_time_series_factory(external_id: str, datapoints: DataPointList) ->
return TimeSeries(external_id=external_id, is_string=is_string)


class BaseTimeSeriesUploadQueue(AbstractUploadQueue):
def _apply_cognite_timeseries(cdf_client: CogniteClient, timeseries_apply: CogniteExtractorTimeSeriesApply) -> NodeId:
instance_result = cdf_client.data_modeling.instances.apply(timeseries_apply)
Comment thread
mathialo marked this conversation as resolved.
Outdated
node = instance_result.nodes[0]
return node.as_id()


class BaseTimeSeriesUploadQueue(AbstractUploadQueue, Generic[IdType]):
"""
Abstract base upload queue for time series

Expand Down Expand Up @@ -120,7 +127,7 @@ def __init__(
cancellation_token,
)

self.upload_queue: dict[EitherId, DataPointList] = {}
self.upload_queue: dict[IdType, DataPointList] = {}

self.points_queued = TIMESERIES_UPLOADER_POINTS_QUEUED
self.points_written = TIMESERIES_UPLOADER_POINTS_WRITTEN
Expand Down Expand Up @@ -204,7 +211,7 @@ def __enter__(self: TQueue) -> TQueue:
return self


class TimeSeriesUploadQueue(BaseTimeSeriesUploadQueue):
class TimeSeriesUploadQueue(BaseTimeSeriesUploadQueue[EitherId]):
"""
Upload queue for time series

Expand Down Expand Up @@ -388,7 +395,7 @@ def _upload_batch(upload_this: list[dict], retries: int = 5) -> list[dict]:
self.queue_size.set(self.upload_queue_size)


class CDMTimeSeriesUploadQueue(BaseTimeSeriesUploadQueue):
class CDMTimeSeriesUploadQueue(BaseTimeSeriesUploadQueue[NodeId]):
"""
Upload queue for CDM time series

Expand All @@ -412,6 +419,7 @@ def __init__(
max_upload_interval: int | None = None,
trigger_log_level: str = "DEBUG",
thread_name: str | None = None,
create_missing: Callable[[CogniteClient, CogniteExtractorTimeSeriesApply], NodeId] | bool = False,
cancellation_token: CancellationToken | None = None,
):
super().__init__(
Expand All @@ -424,10 +432,15 @@ def __init__(
cancellation_token,
)

def _apply_cognite_timeseries(self, timeseries_apply: CogniteExtractorTimeSeriesApply) -> NodeId:
instance_result = self.cdf_client.data_modeling.instances.apply(timeseries_apply)
node = instance_result.nodes[0]
return node.as_id()
self.missing_factory: Callable[[CogniteClient, CogniteExtractorTimeSeriesApply], NodeId]
self.timeseries_apply_dict: dict[NodeId, CogniteExtractorTimeSeriesApply] = {}

if isinstance(create_missing, bool):
self.create_missing = create_missing
self.missing_factory = _apply_cognite_timeseries
else:
self.create_missing = True
self.missing_factory = create_missing

def add_to_upload_queue(
self, *, timeseries_apply: CogniteExtractorTimeSeriesApply, datapoints: DataPointList | None = None
Expand All @@ -442,14 +455,16 @@ def add_to_upload_queue(
"""
datapoints = self._sanitize_datapoints(datapoints)

instance_id = self._apply_cognite_timeseries(timeseries_apply)
either_id = EitherId(instance_id=instance_id)
instance_id = NodeId(timeseries_apply.space, timeseries_apply.external_id)

with self.lock:
if either_id not in self.upload_queue:
self.upload_queue[either_id] = []
if instance_id not in self.upload_queue:
self.upload_queue[instance_id] = []

self.upload_queue[either_id].extend(datapoints)
if instance_id not in self.timeseries_apply_dict:
self.timeseries_apply_dict[instance_id] = timeseries_apply

self.upload_queue[instance_id].extend(datapoints)
self.points_queued.inc(len(datapoints))
self.upload_queue_size += len(datapoints)
self.queue_size.set(self.upload_queue_size)
Expand All @@ -469,9 +484,61 @@ def upload(self) -> None:
max_delay=RETRY_MAX_DELAY,
backoff=RETRY_BACKOFF_FACTOR,
)
def _upload_batch(upload_this: list[dict]) -> list[dict]:
if len(upload_this) > 0:
def _upload_batch(upload_this: list[dict], retries: int = 5) -> list[dict]:
Comment thread
mathialo marked this conversation as resolved.
Outdated
if len(upload_this) == 0:
return upload_this

try:
self.cdf_client.time_series.data.insert_multiple(upload_this)
except CogniteNotFoundError as ex:
if not retries:
raise ex

if not self.create_missing:
self.logger.error("Could not upload data points to %s: %s", str(ex.not_found), str(ex))

# Get IDs of time series that exists, but failed because of the non-existing time series
retry_these = [
NodeId(id_dict["instanceId"]["space"], id_dict["instanceId"]["externalId"])
for id_dict in ex.failed
if id_dict not in ex.not_found
]

if self.create_missing:
# Get the time series that can be created
create_these_ids = set(
[
NodeId(id_dict["instanceId"]["space"], id_dict["instanceId"]["externalId"])
for id_dict in ex.not_found
]
)
self.logger.info(f"Creating {len(create_these_ids)} time series")
to_create: list[NodeId] = [
self.missing_factory(self.cdf_client, self.timeseries_apply_dict[nodeId])
for nodeId in create_these_ids
]

retry_these.extend([nodeId for nodeId in to_create])

if len(ex.not_found) != len(create_these_ids):
missing = [
id_dict
for id_dict in ex.not_found
if NodeId(id_dict["instanceId"]["space"], id_dict["instanceId"]["externalId"])
not in retry_these
]
missing_num = len(ex.not_found) - len(create_these_ids)
self.logger.error(
f"{missing_num} time series not found, and could not be created automatically: "
+ str(missing)
+ " Data will be dropped"
)

# Remove entries with non-existing time series from upload queue
upload_this = [entry for entry in upload_this if entry["instanceId"] in retry_these]

# Upload remaining
_upload_batch(upload_this, retries - 1)

return upload_this
Comment thread
mathialo marked this conversation as resolved.

Expand All @@ -481,13 +548,13 @@ def _upload_batch(upload_this: list[dict]) -> list[dict]:
with self.lock:
upload_this = _upload_batch(
[
{either_id.type(): either_id.content(), "datapoints": list(datapoints)}
for either_id, datapoints in self.upload_queue.items()
{"instanceId": instance_id, "datapoints": list(datapoints)}
for instance_id, datapoints in self.upload_queue.items()
if len(datapoints) > 0
]
)

for _either_id, datapoints in self.upload_queue.items():
for _instance_id, datapoints in self.upload_queue.items():
self.points_written.inc(len(datapoints))

try:
Expand Down
44 changes: 14 additions & 30 deletions cognite/extractorutils/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@

from cognite.client import CogniteClient
from cognite.client.data_classes import Asset, ExtractionPipelineRun, TimeSeries
from cognite.client.data_classes.data_modeling import NodeId
from cognite.client.exceptions import CogniteAPIError, CogniteException, CogniteFileUploadError, CogniteNotFoundError
from cognite.extractorutils.threading import CancellationToken

Expand Down Expand Up @@ -80,41 +79,35 @@ def ensure_assets(cdf_client: CogniteClient, assets: Iterable[Asset]) -> None:

class EitherId:
"""
Class representing an ID in CDF, which can either be an external ID, internal ID or instance ID.
An EitherId can only hold one ID type.
Class representing an ID in CDF, which can either be an external or internal ID. An EitherId can only hold one ID
type, not both.

Args:
id: Internal ID
external_id: external ID. It can be `external_id` or `externalId`
instance_id: Instance ID. It can be `instance_id` or `instanceId`

Raises:
TypeError: If none or more than one of the id types are set.
TypeError: If none of both of id types are set.
"""

def __init__(self, **kwargs: int | str | NodeId | None):
def __init__(self, **kwargs: int | str | None):
internal_id = kwargs.get("id")
external_id = kwargs.get("externalId") or kwargs.get("external_id")
instance_id = kwargs.get("instanceId") or kwargs.get("instance_id")

identifiers = [internal_id, external_id, instance_id]
provided_ids = [id_val for id_val in identifiers if id_val is not None]
if internal_id is None and external_id is None:
raise TypeError("Either id or external_id must be set")

if not provided_ids:
raise TypeError("Either id, external_id, or instance_id must be set")
if len(provided_ids) > 1:
raise TypeError("Only one of id, external_id, or instance_id can be set")
if internal_id is not None and external_id is not None:
raise TypeError("Only one of id and external_id can be set")

if internal_id is not None and not isinstance(internal_id, int):
raise TypeError("Internal IDs must be integers")

if external_id is not None and not isinstance(external_id, str):
raise TypeError("External IDs must be strings")
if instance_id is not None and not isinstance(instance_id, NodeId):
raise TypeError("Instance IDs must be NodeId objects")

self.internal_id: int | None = internal_id
self.external_id: str | None = external_id
self.instance_id: NodeId | None = instance_id

def type(self) -> str:
"""
Expand All @@ -123,21 +116,16 @@ def type(self) -> str:
Returns:
'id' if the EitherId represents an internal ID, 'externalId' if the EitherId represents an external ID
"""
if self.internal_id is not None:
return "id"
elif self.instance_id is not None:
return "instanceId"
else:
return "externalId"
return "id" if self.internal_id is not None else "externalId"

def content(self) -> int | str | NodeId:
def content(self) -> int | str:
"""
Get the value of the ID

Returns:
The ID
"""
return self.internal_id or self.external_id or self.instance_id # type: ignore
return self.internal_id or self.external_id # type: ignore # checked to be not None in init

def __eq__(self, other: Any) -> bool:
"""
Expand All @@ -152,11 +140,7 @@ def __eq__(self, other: Any) -> bool:
if not isinstance(other, EitherId):
return False

return (
self.internal_id == other.internal_id
and self.external_id == other.external_id
and self.instance_id == other.instance_id
)
return self.internal_id == other.internal_id and self.external_id == other.external_id

def __hash__(self) -> int:
"""
Expand All @@ -165,7 +149,7 @@ def __hash__(self) -> int:
Returns:
Hash code of ID
"""
return hash((self.internal_id, self.external_id, self.instance_id))
return hash((self.internal_id, self.external_id))

def __str__(self) -> str:
"""
Expand Down
Loading