Skip to content

Commit c7be0cb

Browse files
authored
fix: More reliable electricity data extraction from M365 (#158)
### Summary More robust extract of electricity data from sharepoint: - Loading now stops if file content cannot be read to avoid silently missing data. - Number of threads capped to 8 - Retry on network errors when reading file content - Content merged on filename - Removed dlt lag widow that didn't function as expected Refs #156
1 parent 0fe2892 commit c7be0cb

6 files changed

Lines changed: 55 additions & 45 deletions

File tree

.github/actions/run-pytest-with-uv/action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ inputs:
1111
pytest-file-or-dir:
1212
description: Path to a file or directory for pytest execution
1313
required: true
14+
python-version:
15+
description: The version of Python to install. Defaults to 3.13 as 3.14 was released but not all dependencies work.
16+
default: "3.13"
1417
uv-cache-dependency-glob:
1518
description: Cache dependencies based on the supplied glob
1619
uv-version:
@@ -25,6 +28,7 @@ runs:
2528
with:
2629
cache-dependency-glob: ${{ inputs.uv-cache-dependency-glob }}
2730
version: ${{ inputs.uv-version }}
31+
python-version: ${{ inputs.python-version }}
2832

2933
- name: Bring up Docker Compose services
3034
if: inputs.compose-file-path != ''

elt-common/src/elt_common/dlt_sources/m365/__init__.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,21 @@
11
"""Reads files from a SharePoint documents library"""
22

3-
from typing import Iterator, List, cast
3+
from typing import Iterator, List
44

55
import dlt
6-
from dlt.common.storages.fsspec_filesystem import MTIME_DISPATCH, glob_files, FileItemDict
6+
from dlt.common.storages.fsspec_filesystem import MTIME_DISPATCH, glob_files
77
from dlt.extract import decorators
88
import dlt.common.logger as logger
99
import pendulum
1010

11-
from .helpers import M365CredentialsResource, M365DriveFS
11+
from .helpers import M365CredentialsResource, M365DriveFS, M365DriveItem
1212
from .settings import DEFAULT_CHUNK_SIZE
1313

1414
# Add our M365DriveFS protocol(s) to the known modificaton time mappings
1515
for protocol in M365DriveFS.protocol:
1616
MTIME_DISPATCH[protocol] = MTIME_DISPATCH["file"]
1717

1818

19-
class M365DriveItem(FileItemDict):
20-
"""Specialises FileItemDict to add 'fetch_bytes' to bypass complicated file reading/caching in
21-
'read_bytes' and just download the file content"""
22-
23-
def read_bytes(self) -> bytes:
24-
drive_fs = cast(M365DriveFS, self.fsspec)
25-
return drive_fs.fetch_all(self["file_url"])
26-
27-
2819
# This is designed to look similar to the dlt.filesystem resource where the resource returns DriveItem
2920
# objects that include the content as raw bytes. The bytes need to be parsed by an appropriate
3021
# transformer
@@ -50,8 +41,8 @@ def sharepoint(
5041
for file_model in glob_files(
5142
sp_library, bucket_url=M365DriveFS.protocol[0] + "://", file_glob=file_glob
5243
):
53-
log_msg = f"Found '{file_model['file_name']}' with modification date '{file_model['modification_date']}'"
54-
if modified_after and file_model["modification_date"] <= modified_after:
44+
log_msg = f"Found '{file_model['file_name']}' with modification date '{file_model['modification_date']}'" # type: ignore
45+
if modified_after and file_model["modification_date"] <= modified_after: # type: ignore
5546
log_msg += ": skipped old item."
5647
continue
5748
else:

elt-common/src/elt_common/dlt_sources/m365/helpers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from fsspec import AbstractFileSystem
77
from dlt.common.configuration.specs import configspec
88
from dlt.common.typing import TSecretStrValue
9+
from dlt.common.storages.fsspec_filesystem import FileItemDict
910
from httpx import Response
1011
from httpx import HTTPStatusError, NetworkError, TimeoutException
1112
import tenacity
@@ -236,3 +237,13 @@ def _msgraph_get(self, url: str, **kwargs) -> Response:
236237
@tenacity.retry(**_RETRY_ARGS)
237238
def _msgraph_request(self, method: str, url: str, **kwargs) -> Response:
238239
return self.client.request(method, url, **kwargs)
240+
241+
242+
class M365DriveItem(FileItemDict):
243+
"""Specialises FileItemDict to add 'fetch_bytes' to bypass complicated file reading/caching in
244+
'read_bytes' and just download the file content"""
245+
246+
@tenacity.retry(**_RETRY_ARGS)
247+
def read_bytes(self) -> bytes:
248+
drive_fs = cast(M365DriveFS, self.fsspec)
249+
return drive_fs.fetch_all(self["file_url"])

infra/local/trino/trino-create-catalog.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ with (
99
"iceberg.rest-catalog.oauth2.credential" = 'localinfra:s3cr3t',
1010
"iceberg.rest-catalog.oauth2.scope" = 'lakekeeper offline_access',
1111
"fs.native-s3.enabled" = 'true',
12-
"s3.endpoint" = 'http://traefik:9000',
12+
"s3.endpoint" = 'http://minio:59000',
1313
"s3.region" = 'local-01',
1414
"s3.path-style-access" = 'true',
1515
"s3.aws-access-key" = 'adpuser',
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
[electricity_sharepoint.sources.m365]
2+
file_glob = "/General/RDM Data/**/*.*"
23
files_per_page = 200
34
skip_rows = 7
5+
max_workers = 6

warehouses/accelerator/extract_load/electricity_sharepoint/extract_and_load.py

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,24 @@
3333
LOGGER = logging.getLogger(__name__)
3434

3535

36+
MAX_WORKERS_DEFAULT = min(8, (os.cpu_count() or 1) + 4)
3637
EXCEL_ENGINE = "calamine"
38+
ONE_DAY_SECS = 24 * 60 * 60
3739
PIPELINE_NAME = "electricity_sharepoint"
3840
RDM_TIMEZONE = "Europe/London"
3941
SITE_URL = "https://stfc365.sharepoint.com/sites/ISISSustainability"
40-
LAG_WINDOW_SECONDS = 24 * 60 * 60
41-
# It was observed that trying to load too many files concurrently from a sharepoint drive
42-
# randomly resulted in empty content. Lower the maximum number of threads that can be used
43-
# to extract the data
44-
MAX_WORKERS_DEFAULT = min(10, (os.cpu_count() or 1) + 4)
42+
43+
44+
def effective_max_workers(max_workers: int | None) -> int:
45+
# For high-cpu-count machines the ThreadPoolExecutor default ends up being too high.
46+
# It has been observed that too many concurrent threads cause issues with partial file reads from
47+
# OneDrive. 8 threads seems to work correctly so clamp this as them maximum...
48+
49+
return (
50+
min(MAX_WORKERS_DEFAULT, max_workers)
51+
if max_workers is not None
52+
else MAX_WORKERS_DEFAULT
53+
)
4554

4655

4756
def to_utc(ts: pd.Series) -> pd.Series:
@@ -59,7 +68,7 @@ def read_power_consumption_csv(
5968
"""
6069
df = pd.read_csv(file_content, skiprows=skip_rows)
6170
df["DateTime"] = to_utc(
62-
pd.to_datetime(df["Date"] + " " + df["Time"], format="%d/%m/%y %H:%M:%S")
71+
pd.to_datetime(df["Date"] + " " + df["Time"], format="%d/%m/%y %H:%M:%S") # type: ignore
6372
)
6473
return df.drop(["Date", "Time"], axis=1)
6574

@@ -92,34 +101,33 @@ def extract_content_and_read(
92101
:param items: An iterator of dicts describing the file content
93102
:param skip_rows: Number of rows in the csv/xlsx files to skip
94103
:param max_workers (optional): How many threads to use to process the files.
95-
Defaults to a maximum of MAX_WORKERS_DEFAULT.
104+
Defaults to a maximum defined by concurrent.futures.ThreadPoolExecutor
96105
"""
97106

98107
# The files are all independent. Process them in parallel and combine for a single yield
99108
def read_as_dataframe(file_obj: M365DriveItem) -> pd.DataFrame | None:
100-
file_content = io.BytesIO(file_obj.read_bytes())
109+
file_name = file_obj["file_name"]
110+
file_bytes = file_obj.read_bytes()
111+
LOGGER.debug(f"Filename '{file_name}' has size {len(file_bytes)} bytes.")
101112
try:
102-
match pathlib.Path(file_obj["file_name"]).suffix:
113+
match pathlib.Path(file_name).suffix:
103114
case ".csv":
104-
df = read_power_consumption_csv(file_content, skip_rows)
115+
df = read_power_consumption_csv(io.BytesIO(file_bytes), skip_rows)
105116
case ".xlsx":
106-
df = read_power_consumption_excel(file_content, skip_rows)
117+
df = read_power_consumption_excel(io.BytesIO(file_bytes), skip_rows)
107118
case _:
108-
raise RuntimeError(
109-
f"Unsupported file extension in '{file_obj['file_name']}'"
110-
)
111-
except ValueError as exc:
112-
LOGGER.warning(
113-
f"Error reading '{file_obj['file_name']}': {str(exc)}. Skipping"
114-
)
115-
df = None
119+
raise RuntimeError(f"Unsupported file extension in '{file_name}'")
120+
except pd.errors.EmptyDataError as exc:
121+
raise RuntimeError(
122+
f"'{file_name} ({len(file_bytes)} bytes)': {str(exc)}"
123+
) from exc
116124

125+
df["file_name"] = file_obj["file_name"]
117126
return df
118127

119128
df_batch = None
120-
effective_max_workers = min(MAX_WORKERS_DEFAULT, max_workers or MAX_WORKERS_DEFAULT)
121129
with concurrent.futures.ThreadPoolExecutor(
122-
max_workers=effective_max_workers
130+
max_workers=effective_max_workers(max_workers)
123131
) as executor:
124132
future_to_file_item = {
125133
executor.submit(read_as_dataframe, file_obj): file_obj for file_obj in items
@@ -132,24 +140,18 @@ def read_as_dataframe(file_obj: M365DriveItem) -> pd.DataFrame | None:
132140
yield df_batch
133141

134142

135-
# Occasionally pandas raises a EmptyDataError when parsing the CSV content indicating
136-
# there is no data. This is likely an issue with a file being listed but no content yet available.
137-
# We skip these files but want to make sure we grab them next time so we use the lag functionality
138-
# to look back LAG_WINDOW_SECONDS when the next load is run.
139-
@dlt.resource(merge_key="DateTime")
143+
@dlt.resource(merge_key="file_name")
140144
def rdm_data(
141145
datetime_cur=dlt.sources.incremental(
142146
"DateTime",
143147
initial_value=pendulum.DateTime.EPOCH,
144-
lag=LAG_WINDOW_SECONDS,
145-
last_value_func=max,
146148
),
147149
) -> Iterator[TDataItems]:
148150
files = sharepoint(
149151
site_url=SITE_URL,
150-
file_glob="/General/RDM Data/**/*.*",
151152
extract_content=False,
152-
modified_after=datetime_cur.start_value,
153+
modified_after=datetime_cur.start_value
154+
- pendulum.Duration(seconds=ONE_DAY_SECS),
153155
)
154156
reader = files | extract_content_and_read()
155157
yield from reader

0 commit comments

Comments
 (0)