Skip to content

Commit 71bc5ac

Browse files
committed
fix: handle datetime schema mismatches when concatenating parquet files
- Convert all datetime columns to UTC timezone-aware format before concatenation - Use diagonal_relaxed concat to gracefully handle schema differences - Fixes SchemaError when running 'slurm-usage analyze' with mixed datetime schemas The issue occurred when different parquet files had inconsistent datetime schemas (some timezone-aware, some naive), causing concat operations to fail. This fix ensures all datetime columns are standardized to UTC before merging.
1 parent 60321f7 commit 71bc5ac

2 files changed

Lines changed: 174 additions & 1 deletion

File tree

slurm_usage.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1145,6 +1145,16 @@ def _load_recent_data(
11451145
for f in parquet_files:
11461146
try:
11471147
df = pl.read_parquet(f)
1148+
# Ensure consistent datetime schema - convert to UTC if needed
1149+
for col in df.columns:
1150+
col_type = df[col].dtype
1151+
# Check if it's a datetime type
1152+
if isinstance(col_type, pl.Datetime):
1153+
# Convert to timezone-aware UTC datetime
1154+
if col_type.time_zone is None:
1155+
df = df.with_columns(pl.col(col).dt.replace_time_zone("UTC"))
1156+
elif col_type.time_zone != "UTC":
1157+
df = df.with_columns(pl.col(col).dt.convert_time_zone("UTC"))
11481158
dfs.append(df)
11491159
except (OSError, pl.exceptions.ComputeError) as e: # noqa: PERF203
11501160
console.print(f"[yellow]Warning: Could not read {f}: {e}[/yellow]")
@@ -1153,7 +1163,8 @@ def _load_recent_data(
11531163
if not dfs:
11541164
return None
11551165

1156-
combined = pl.concat(dfs)
1166+
# Use diagonal concat to handle schema differences more gracefully
1167+
combined = pl.concat(dfs, how="diagonal_relaxed")
11571168

11581169
# Deduplicate by job_id if processing processed data
11591170
if data_type == "processed" and "job_id" in combined.columns:

tests/test_data_processing.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
import os
66
import re
77
import sys
8+
import tempfile
9+
from datetime import datetime, timezone
810
from pathlib import Path
911

12+
import polars as pl
1013
import pytest
1114

1215
# Add parent directory to path
@@ -402,3 +405,162 @@ def test_parse_gres_multiple_sockets(self) -> None:
402405
cleaned_gres = re.sub(r"\(S:[0-9-]+\)", "", gres)
403406
gpu_parts = cleaned_gres.split(":")
404407
assert int(gpu_parts[-1]) == expected_count
408+
409+
410+
class TestDatetimeSchemaConsistency:
411+
"""Test datetime schema consistency when loading and concatenating data."""
412+
413+
def test_load_recent_data_with_mixed_datetime_schemas(self) -> None:
414+
"""Test that _load_recent_data handles mixed datetime schemas correctly."""
415+
with tempfile.TemporaryDirectory() as tmpdir:
416+
# Create config with temp directory
417+
config = slurm_usage.Config(
418+
data_dir=Path(tmpdir),
419+
groups={},
420+
user_to_group={},
421+
)
422+
423+
# Create processed subdirectory
424+
processed_dir = Path(tmpdir) / "processed"
425+
processed_dir.mkdir(parents=True, exist_ok=True)
426+
427+
# Create test data with different datetime schemas
428+
# DataFrame 1: UTC timezone-aware datetime
429+
df1 = pl.DataFrame({
430+
"job_id": ["job1", "job2"],
431+
"user": ["alice", "bob"],
432+
"cpu_hours_used": [1.0, 2.0],
433+
"processed_date": [
434+
datetime(2025, 9, 20, 10, 0, 0, tzinfo=timezone.utc),
435+
datetime(2025, 9, 20, 11, 0, 0, tzinfo=timezone.utc),
436+
],
437+
"is_complete": [True, True],
438+
})
439+
440+
# DataFrame 2: Naive datetime (no timezone)
441+
df2 = pl.DataFrame({
442+
"job_id": ["job3", "job4"],
443+
"user": ["charlie", "dave"],
444+
"cpu_hours_used": [3.0, 4.0],
445+
"processed_date": [
446+
datetime(2025, 9, 21, 10, 0, 0),
447+
datetime(2025, 9, 21, 11, 0, 0),
448+
],
449+
"is_complete": [True, True],
450+
})
451+
452+
# Save DataFrames as parquet files
453+
df1.write_parquet(processed_dir / "2025-09-20.parquet")
454+
df2.write_parquet(processed_dir / "2025-09-21.parquet")
455+
456+
# Load data using the function
457+
result = slurm_usage._load_recent_data(config, days=2)
458+
459+
# Verify the data was loaded and concatenated successfully
460+
assert result is not None
461+
assert len(result) == 4
462+
assert "job_id" in result.columns
463+
assert "processed_date" in result.columns
464+
465+
# Check that all datetime columns are now UTC timezone-aware
466+
date_col_type = result["processed_date"].dtype
467+
assert isinstance(date_col_type, pl.Datetime)
468+
assert date_col_type.time_zone == "UTC"
469+
470+
def test_load_recent_data_handles_empty_directory(self) -> None:
471+
"""Test that _load_recent_data handles empty directory gracefully."""
472+
with tempfile.TemporaryDirectory() as tmpdir:
473+
config = slurm_usage.Config(
474+
data_dir=Path(tmpdir),
475+
groups={},
476+
user_to_group={},
477+
)
478+
479+
processed_dir = Path(tmpdir) / "processed"
480+
processed_dir.mkdir(parents=True, exist_ok=True)
481+
482+
# Should return None for empty directory
483+
result = slurm_usage._load_recent_data(config, days=1)
484+
assert result is None
485+
486+
def test_diagonal_concat_handles_schema_differences(self) -> None:
487+
"""Test that diagonal_relaxed concat handles schema differences gracefully."""
488+
with tempfile.TemporaryDirectory() as tmpdir:
489+
config = slurm_usage.Config(
490+
data_dir=Path(tmpdir),
491+
groups={},
492+
user_to_group={},
493+
)
494+
495+
processed_dir = Path(tmpdir) / "processed"
496+
processed_dir.mkdir(parents=True, exist_ok=True)
497+
498+
# DataFrame with different columns
499+
df1 = pl.DataFrame({
500+
"job_id": ["job1"],
501+
"user": ["alice"],
502+
"cpu_hours_used": [1.0],
503+
"processed_date": [datetime(2025, 9, 20, 10, 0, 0)],
504+
"is_complete": [True],
505+
})
506+
507+
# DataFrame with additional column
508+
df2 = pl.DataFrame({
509+
"job_id": ["job2"],
510+
"user": ["bob"],
511+
"cpu_hours_used": [2.0],
512+
"gpu_hours_used": [0.5], # Additional column
513+
"processed_date": [datetime(2025, 9, 21, 10, 0, 0)],
514+
"is_complete": [True],
515+
})
516+
517+
df1.write_parquet(processed_dir / "2025-09-20.parquet")
518+
df2.write_parquet(processed_dir / "2025-09-21.parquet")
519+
520+
# Should handle schema differences with diagonal_relaxed
521+
result = slurm_usage._load_recent_data(config, days=2)
522+
523+
assert result is not None
524+
assert len(result) == 2
525+
# The gpu_hours_used column should exist with null for first row
526+
assert "gpu_hours_used" in result.columns
527+
528+
def test_multiple_datetime_columns_converted(self) -> None:
529+
"""Test that all datetime columns are converted to UTC."""
530+
with tempfile.TemporaryDirectory() as tmpdir:
531+
config = slurm_usage.Config(
532+
data_dir=Path(tmpdir),
533+
groups={},
534+
user_to_group={},
535+
)
536+
537+
processed_dir = Path(tmpdir) / "processed"
538+
processed_dir.mkdir(parents=True, exist_ok=True)
539+
540+
# Use today's date for the filename
541+
from datetime import date
542+
today = date.today()
543+
544+
# DataFrame with multiple datetime columns
545+
df = pl.DataFrame({
546+
"job_id": ["job1"],
547+
"user": ["alice"],
548+
"submit_time": [datetime(2025, 9, 20, 9, 0, 0)], # Naive
549+
"start_time": [datetime(2025, 9, 20, 10, 0, 0, tzinfo=timezone.utc)], # UTC
550+
"end_time": [datetime(2025, 9, 20, 11, 0, 0)], # Naive
551+
"processed_date": [datetime(2025, 9, 20, 12, 0, 0)], # Naive
552+
"is_complete": [True],
553+
})
554+
555+
df.write_parquet(processed_dir / f"{today}.parquet")
556+
557+
result = slurm_usage._load_recent_data(config, days=1)
558+
559+
assert result is not None
560+
561+
# Check all datetime columns are UTC
562+
for col in ["submit_time", "start_time", "end_time", "processed_date"]:
563+
if col in result.columns:
564+
col_type = result[col].dtype
565+
if isinstance(col_type, pl.Datetime):
566+
assert col_type.time_zone == "UTC", f"Column {col} should be UTC"

0 commit comments

Comments
 (0)