Skip to content

Commit acaf522

Browse files
authored
perf: cache entities during orphan and group rejections to avoid recomputing multiple times (#172)
1 parent 9415d2a commit acaf522

9 files changed

Lines changed: 303 additions & 50 deletions

File tree

‎src/dve/core_engine/backends/base/rules.py‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from abc import ABC, abstractmethod
55
from collections import defaultdict
66
from collections.abc import Iterable, Iterator
7-
from typing import Any, ClassVar, Generic, NoReturn, Optional, TypeVar
7+
from typing import Any, ClassVar, Generic, MutableMapping, NoReturn, Optional, TypeVar
88
from uuid import uuid4
99

1010
from typing_extensions import Literal, Protocol, get_type_hints
@@ -59,6 +59,8 @@
5959
"""A convenience type indicating a mapping from config type to step method."""
6060
Stage = Literal["Pre-filter", "Filter", "Post-filter"]
6161
"""The name of a stage within a rule."""
62+
TempTableName = str
63+
"""temp tables to cache intermediate results"""
6264

6365

6466
class _UnboundStepFunction(Generic[T_contra], Protocol): # pylint: disable=too-few-public-methods
@@ -132,6 +134,7 @@ def __init__( # pylint: disable=unused-argument
132134
):
133135
self.logger = logger or get_logger(type(self).__name__)
134136
"""The `logging.Logger instance for the data contract config."""
137+
self.entity_cache_tracker: MutableMapping[EntityName, TempTableName] = {}
135138

136139
@classmethod
137140
@abstractmethod
@@ -312,9 +315,7 @@ def join_header(self, entities: Entities, *, config: HeaderJoin) -> Messages:
312315
raise NotImplementedError
313316

314317
@abstractmethod
315-
def identify_orphans(
316-
self, entities: Entities, *, config: OrphanIdentification
317-
) -> Iterable:
318+
def identify_orphans(self, entities: Entities, *, config: OrphanIdentification) -> Iterable:
318319
"""Identify records in an entity which don't have at least one corresponding
319320
match in the target. A new boolean column will be added to `entity` ('IsOrphaned')
320321
indicating whether the condition matched.
@@ -413,9 +414,7 @@ def process_node(node: HierarchyNode) -> bool:
413414
entity=node.entity_name,
414415
record=record, # type: ignore
415416
error_location=location,
416-
error_message=template_object(
417-
node.missing_parent_id_error_message, record
418-
),
417+
error_message=template_object(node.missing_parent_id_error_message, record),
419418
failure_type="record",
420419
error_type="record",
421420
error_code=node.missing_parent_id_error_code,
@@ -426,6 +425,8 @@ def process_node(node: HierarchyNode) -> bool:
426425
]
427426
msg_writer.write_queue.put(_messages)
428427

428+
self.cache_entity(node.entity_name, entities)
429+
429430
return len(_messages) > 0
430431

431432
entity_issues_found: dict[EntityName, bool] = {}
@@ -434,8 +435,6 @@ def process_node(node: HierarchyNode) -> bool:
434435
for node in tree.iterate_root_down():
435436
entity_issues_found[node.entity_name] = process_node(node)
436437

437-
entities.update(entities)
438-
439438
return [], entity_issues_found
440439

441440
def identify_and_remove_missing_mandatory_groups(
@@ -493,6 +492,7 @@ def process_node(node: HierarchyNode) -> bool:
493492
for record in missing_children_records
494493
]
495494
msg_writer.write_queue.put(_messages)
495+
self.cache_entity(node.parent_entity, entities)
496496
return len(_messages) > 0
497497

498498
entity_issues_found: dict[EntityName, bool] = {}
@@ -502,8 +502,6 @@ def process_node(node: HierarchyNode) -> bool:
502502
if node.parent_entity and node.mandatory:
503503
entity_issues_found[node.parent_entity] = process_node(node)
504504

505-
# entities.update(entities)
506-
507505
return [], entity_issues_found
508506

509507
# pylint: disable=R0912,R0914
@@ -852,3 +850,20 @@ def filter_data_contract_record_rejections(
852850
def get_entity_count(entity: EntityType) -> int:
853851
"""Method to get count of records in entity"""
854852
raise NotImplementedError()
853+
854+
def cache_entity(self, entity_name: EntityName, entities: Entities):
855+
"""Store the materialised query in memory and update entity to query directly.
856+
If the entity is already cached, the new cache should be created first, then the old one
857+
removed as part of the function (in case the newer cache depends on the older one)."""
858+
raise NotImplementedError()
859+
860+
def _remove_cached_artifact(self, entity_name: EntityName):
861+
"""Delete artifact in memory and clear from the entity cache keeping track.
862+
This should not be used directly as removing artifacts from memory, may lead to some
863+
entities being unable to be processed as their execution plans depend on these artifacts."""
864+
raise NotImplementedError()
865+
866+
def clear_entity_cache(self):
867+
"""Helper method to remove all artifacts and cache trackers at end of processing."""
868+
for entity_name in list(self.entity_cache_tracker):
869+
self._remove_cached_artifact(entity_name)

‎src/dve/core_engine/backends/implementations/duckdb/duckdb_helpers.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,12 @@ def table_exists(connection: DuckDBPyConnection, table_name: str) -> bool:
102102
"""check if a table exists in a given DuckDBPyConnection"""
103103
return table_name in get_all_existing_ddb_tables(connection)
104104

105+
105106
def get_all_existing_ddb_tables(connection: DuckDBPyConnection) -> tuple[str]:
106107
"""Fetch all tables available ina given duckdb connection"""
107108
return tuple(itertools.chain.from_iterable(connection.sql("SHOW TABLES").fetchall()))
108109

110+
109111
def relation_is_empty(relation: DuckDBPyRelation) -> bool:
110112
"""Check if a duckdb relation is empty"""
111113
if relation.limit(1).shape[0] > 0:
@@ -292,7 +294,7 @@ def _ddb_filter_contract_errors(
292294
},
293295
)
294296
.filter(
295-
f"FailureType == 'record' AND Status != 'informational' AND OriginalEntity = '{entity_name}'" # pylint: disable=C0301
297+
f"FailureType == 'record' AND Status != 'informational' AND OriginalEntity = '{entity_name}'" # pylint: disable=C0301
296298
) # pylint: disable=C0301
297299
.select("RecordIndex")
298300
.distinct()

‎src/dve/core_engine/backends/implementations/duckdb/rules.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Business rule definitions for duckdb backend"""
2+
23
# pylint: disable=R0801
34
from collections.abc import Callable, Iterable, Iterator
45
from typing import get_type_hints
@@ -60,7 +61,10 @@
6061
from dve.core_engine.functions import implementations as functions
6162
from dve.core_engine.message import FeedbackMessage
6263
from dve.core_engine.templating import template_object
63-
from dve.core_engine.type_hints import Messages
64+
from dve.core_engine.type_hints import EntityName, Messages
65+
66+
TempTableName = str
67+
"""temp tables to cache intermediate results"""
6468

6569

6670
@duckdb_get_entity_count
@@ -432,6 +436,7 @@ def identify_orphans(
432436
"semi",
433437
)
434438
)
439+
435440
filtered_rel = (
436441
entities[config.entity_name]
437442
.set_alias(config.entity_name)
@@ -582,3 +587,21 @@ def notify(self, entities: DuckDBEntities, *, config: Notification) -> Messages:
582587
)
583588
)
584589
return messages
590+
591+
def cache_entity(self, entity_name: EntityName, entities: DuckDBEntities):
592+
"""Store the materialised query in memory and update entity to query directly.
593+
If the entity is already cached, the new cache should be created first, then the old one
594+
removed as part of the function (in case the newer cache depends on the older one)."""
595+
_tmp_name = f"{entity_name}_{uuid4().hex}"
596+
597+
if entity := entities.get(entity_name): # pylint: disable=W0612
598+
self.connection.sql(f"CREATE OR REPLACE TEMP TABLE {_tmp_name} AS SELECT * FROM entity")
599+
entities[entity_name] = self.connection.table(_tmp_name)
600+
601+
self._remove_cached_artifact(entity_name)
602+
603+
self.entity_cache_tracker[entity_name] = _tmp_name
604+
605+
def _remove_cached_artifact(self, entity_name: EntityName):
606+
if _tbl := self.entity_cache_tracker.pop(entity_name, None):
607+
self.connection.sql(f"DROP TABLE IF EXISTS {_tbl}")

‎src/dve/core_engine/backends/implementations/spark/rules.py‎

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Step implementations in Spark."""
2+
23
# pylint: disable=R0801
34
from collections.abc import Callable, Iterator
45
from typing import Optional
@@ -50,7 +51,7 @@
5051
from dve.core_engine.functions import implementations as functions
5152
from dve.core_engine.message import FeedbackMessage
5253
from dve.core_engine.templating import template_object
53-
from dve.core_engine.type_hints import Messages
54+
from dve.core_engine.type_hints import EntityName, Messages
5455

5556

5657
@spark_get_entity_count
@@ -343,39 +344,7 @@ def identify_orphans(
343344
self, entities: SparkEntities, *, config: OrphanIdentification
344345
) -> tuple[Messages, int]:
345346
# TODO - adjust this to new setup of identify and remove orphans
346-
source_df: DataFrame = entities[config.entity_name]
347-
source_df = source_df.alias(config.entity_name)
348-
target_df: DataFrame = entities[config.target_name]
349-
target_df = target_df.alias(config.target_name)
350-
351-
key_name = f"key_{uuid4().hex}"
352-
source_df = source_df.withColumn(key_name, sf.expr("uuid()")).alias(config.entity_name)
353-
match_name = f"matched_{uuid4().hex}"
354-
target_df = target_df.withColumn(match_name, lit(1)).alias(config.target_name)
355-
356-
joined_df = (
357-
source_df.join(target_df, on=sf.expr(config.join_condition), how="left")
358-
.groupBy(col(key_name))
359-
.agg(sf.coalesce(sf.sum(col(match_name)) == lit(0), lit(True)).alias("IsOrphaned"))
360-
)
361-
362-
if "IsOrphaned" not in source_df.columns:
363-
result = source_df.join(joined_df, on=[key_name], how="left").drop(key_name)
364-
else:
365-
result = source_df.alias("source").join(
366-
joined_df.alias("joined"),
367-
on=col(f"source.{key_name}") == col(f"joined.{key_name}"),
368-
how="left",
369-
)
370-
371-
columns = {name: col(f"source.{name}") for name in source_df.columns}
372-
columns["IsOrphaned"] = col("source.IsOrphaned") | col("joined.IsOrphaned")
373-
columns.pop(key_name, None)
374-
375-
result = result.select(*[column.alias(name) for name, column in columns.items()])
376-
377-
entities[config.new_entity_name or config.entity_name] = result
378-
return [], 0
347+
raise NotImplementedError
379348

380349
def check_mandatory_group(
381350
self, entities: SparkEntities, *, config: GroupIdentification
@@ -436,3 +405,26 @@ def notify(self, entities: SparkEntities, *, config: Notification) -> Messages:
436405
)
437406
)
438407
return messages
408+
409+
def cache_entity(self, entity_name: str, entities: SparkEntities):
410+
"""Store the materialised query in memory and update entity to query directly.
411+
If the entity is already cached, the new cache should be created first, then the old one
412+
removed as part of the function (in case the newer cache depends on the older one)."""
413+
if entity_name not in entities:
414+
return
415+
416+
_tmp_name = f"{entity_name}_{uuid4().hex}"
417+
418+
entity = entities[entity_name]
419+
entity.createOrReplaceTempView(_tmp_name)
420+
self.spark_session.sql(f"CACHE TABLE {_tmp_name}")
421+
self.spark_session.sql(f"SELECT count(*) FROM {_tmp_name}")
422+
entity = self.spark_session.table(_tmp_name)
423+
self._remove_cached_artifact(entity_name)
424+
self.entity_cache_tracker[entity_name] = _tmp_name
425+
426+
entities[entity_name] = entity
427+
428+
def _remove_cached_artifact(self, entity_name: EntityName):
429+
if _tbl := self.entity_cache_tracker.pop(entity_name, None):
430+
self.spark_session.sql(f"DROP TABLE IF EXISTS {_tbl}")

‎src/dve/core_engine/configuration/v1/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,8 @@ def _check_non_root_entities_have_a_defined_parent(self):
134134
"""Check that non root entities have a parent defined."""
135135
if not self.is_root_entity and self.parent_entity is None:
136136
raise ValueError(
137-
'Non-root entity has no defined parent entity. If you intend this to be a root ' \
138-
'entity you must specify `"is_root_entity": true` for the entity. ' \
137+
"Non-root entity has no defined parent entity. If you intend this to be a root "
138+
'entity you must specify `"is_root_entity": true` for the entity. '
139139
'Otherwise you must specify a `"parent_entity": "<EntityName>"` for this entity.'
140140
)
141141
return self

‎src/dve/pipeline/pipeline.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,8 @@ def apply_business_rules( # pylint: disable=R0914,R0915
759759
final_projection
760760
)
761761

762+
self.step_implementations.clear_entity_cache() # type: ignore
763+
762764
fh.remove_prefix(
763765
fh.joinuri(
764766
self.processed_files_path, submission_info.submission_id, "temp_business_rules"
@@ -775,7 +777,7 @@ def apply_business_rules( # pylint: disable=R0914,R0915
775777
self.processed_files_path,
776778
submission_info.submission_id,
777779
"data_contract",
778-
rules.global_variables.get('entity', submission_info.dataset_id)
780+
rules.global_variables.get("entity", submission_info.dataset_id),
779781
)
780782
)
781783
)

‎src/dve/reporting/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
"""Error reports module."""
2+
23
# pylint: disable=R0801

0 commit comments

Comments
 (0)