Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions benchmarks/DATASET.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ The main groups are:

The exported probe file stores `timestamp` as physical Parquet `INT64` with a microsecond timestamp annotation (`isAdjustedToUTC=false`).
The registered Athena table deliberately exposes its integer values as `bigint`; the original probe validation checked these values against the exported file.
The benchmark projections preserve this integer type, so this workload does not benchmark native timestamp conversion.
The benchmark projections preserve this integer type; only the temporal projection, described in [Tables used for measurements](#tables-used-for-measurements), adds temporal columns derived from it.
For a human-readable UTC display, use `from_unixtime(CAST("timestamp" AS double) / 1000000, 'UTC')`; retain the integer column when exact microsecond values matter.
Nested fields can be null and remain nested in the exported files.

Expand Down Expand Up @@ -233,6 +233,9 @@ Reuse a prepared manifest and its tables when comparing revisions.

The flat projection selects 15 scalar columns, including selected fields from `file`, `details`, and `http`.
The nested projection selects nine top-level columns and preserves all three structures.
Both omit `download_date` from the measured result and read the same prepared table without a further `LIMIT`; their definitions are in [config.py](pyathena_bench/config.py).
The json projection adds `details` as a column of Athena's `json` type to the flat projection.
The temporal projection adds six columns derived from `from_unixtime("timestamp" / 1e6)` to the flat projection, at millisecond precision: a `timestamp(3)`, a `date`, `timestamp(3) with time zone` columns in `UTC`, `Asia/Tokyo`, and `+09:00`, and a `time(3)`.
Its UNLOAD and CTAS reads add only the `timestamp(3)` and `date` columns, because UNLOAD and CTAS cannot write the other types.
All projections omit `download_date` from the measured result and read the same prepared table without a further `LIMIT`; their definitions are in [config.py](pyathena_bench/config.py).
Full cleanup removes these scratch tables and their data, while `cleanup --trials-only` preserves them for another run.
Neither mode deletes the original source table or its S3 partition.
9 changes: 7 additions & 2 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ The Athena timings describe queries against these smaller snapshots, not a scan
The flat workload projects scalar fields from `file`, `details`, and `http` alongside the top-level scalar columns.
The nested workload preserves those structures.
The json workload adds `CAST(details AS JSON) AS details_json` to the flat projection; it covers native pandas and Wrangler reads in the single-query suite, with their UNLOAD and CTAS reads marked unsupported, because Parquet cannot hold the `json` type.
The temporal workload adds columns derived from `"timestamp"` with `from_unixtime()` to the flat projection: `event_time` (`timestamp(3)`), `event_date` (`date`), `event_time_utc`, `event_time_zone`, and `event_time_offset` (`timestamp(3) with time zone` in `UTC`, `Asia/Tokyo`, and `+09:00`), and `event_clock` (`time(3)`).
It covers every family in the single-query suite; its UNLOAD and CTAS reads add only `event_time` and `event_date`, because UNLOAD and CTAS cannot write `time` or `with time zone` types.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round two (claims, callers, operations), base 7b2a084, initial head 7e6349b, repaired head 998a3b6.

Claims checked: the PR body, README, DATASET.md, the comments and docstrings in config.py and cases.py, and the commit messages.

Result: FINDINGS, repaired.

  1. README and DATASET.md said that Athena cannot write time or with time zone types to Parquet. The evidence covers only UNLOAD and CTAS (NOT_SUPPORTED: Unsupported Hive type); Iceberg Parquet is a separate path that I did not check. Repaired in 998a3b6: the wording now names UNLOAD and CTAS.
  2. The PR body still named America/New_York, tested commit 1bf7f02, and the old test name, and it left out the ValueError for an unknown transport. Repaired in the PR description.

No workload converts every library's output into a common Python object representation.

## Dependencies and local checks
Expand Down Expand Up @@ -176,6 +178,9 @@ uv run --no-sync python -m pyathena_bench run \
uv run --no-sync python -m pyathena_bench run \
--manifest results/input.json --out results/nested-small \
--suite single --scale small --shape nested --output-kind native
uv run --no-sync python -m pyathena_bench run \
--manifest results/input.json --out results/temporal-small \
--suite single --scale small --shape temporal
```

The concurrency suite compares ThreadPool and native asyncio APIs at 1, 10, 50, and 100 simultaneous queries by default.
Expand Down Expand Up @@ -300,7 +305,7 @@ When the temporary directory is a tmpfs, as `/tmp` is on Amazon Linux 2023, file
| Arrow CSV and UNLOAD | Eager table loading; arraysize changes row extraction, not S3 streaming |
| Polars CSV and UNLOAD | Full result or chunk iterator |
| SQLAlchemy (`sqla_*`) | `pandas.read_sql_query()` through an engine of each PyAthena driver, whole or with `chunksize` |
| Wrangler CSV | Scalar projections and the json workload; nested cases are marked unsupported |
| Wrangler CSV | Scalar projections, including the json and temporal workloads; nested cases are marked unsupported |
| Wrangler CTAS and UNLOAD | Full DataFrame or chunks, including nested workloads |
| S3 filesystem (`s3`) | Read, write, and multipart copy of one object with `S3FileSystem` and `AioS3FileSystem` |

Expand All @@ -310,7 +315,7 @@ As a cursor is, the engine's connection is opened during setup, outside `total_s
`pandas.read_sql_query()` builds DataFrames from the rows of `fetchall()` or `fetchmany()`, so these cases include the driver's row output; `sqla_rest` pages through GetQueryResults like Cursor.

The [Wrangler API documentation](https://aws-sdk-pandas.readthedocs.io/en/stable/stubs/awswrangler.athena.read_sql_query.html) describes its CSV nested-type restriction.
CTAS and UNLOAD have separate limitations, including timestamp-with-time-zone columns; the supplied timestamp is a bigint.
CTAS and UNLOAD have separate limitations, including `time` and timestamp-with-time-zone columns, which the temporal workload leaves out of its CTAS and UNLOAD reads.
Unsupported combinations are included in the plan and report with reasons.
An attempted operation that fails is recorded as a failure, not silently reclassified as unsupported.
Row-count validation detects incomplete consumption; it is not a proof that different libraries produce identical dtypes or nested Python objects.
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/pyathena_bench/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ def prepare_fixtures(
if key in manifest["fixtures"]:
continue
sql = select_sql(
resources["ScratchDatabase"], manifest["scales"][scale]["table"], shape
resources["ScratchDatabase"], manifest["scales"][scale]["table"], shape, transport
)
root = (
f"s3://{resources['Bucket']}/runs/{manifest['run_id']}/fixtures/{uuid.uuid4().hex}/"
Expand Down
11 changes: 8 additions & 3 deletions benchmarks/pyathena_bench/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
FILESYSTEM_OPERATIONS = ("read", "write", "copy")
ALL_FAMILIES = (*FAMILIES, "wrangler", *SQLALCHEMY_FAMILIES, FILESYSTEM_FAMILY)
SUITES = ("single", "concurrent", "init", "filesystem")
SHAPES = ("flat", "nested", "json")
SHAPES = ("flat", "nested", "json", "temporal")


@dataclass(frozen=True)
Expand Down Expand Up @@ -100,14 +100,19 @@ def matrix(settings: Settings, suite: str, shape: str) -> list[Case]:
``json`` type; the initialization suite, which prepares UNLOAD fixtures too,
has no json cases.

The ``temporal`` shape adds temporal columns to the flat projection and
covers every family in the single-query suite, UNLOAD and CTAS reads
included (see ``select_sql()``). Like json, it has no cases in the other
suites.

The ``filesystem`` suite does not query Athena and has no shape; its cases
belong to the ``flat`` shape.

Args:
settings: Configuration with the arraysizes, chunk sizes, and concurrency,
and the filesystem suite's max_workers and connection pools.
suite: ``single``, ``concurrent``, ``init``, or ``filesystem``.
shape: ``flat``, ``nested``, or ``json``.
shape: ``flat``, ``nested``, ``json``, or ``temporal``.

Returns:
The cases in a stable order.
Expand Down Expand Up @@ -135,7 +140,7 @@ def matrix(settings: Settings, suite: str, shape: str) -> list[Case]:
for workers in settings.filesystem_max_workers
for pool in settings.filesystem_pool_connections
]
if shape == "json" and suite != "single":
if shape in {"json", "temporal"} and suite != "single":
return []
cases: list[Case] = []
arraysizes = settings.arraysizes if suite == "single" else [1000]
Expand Down
37 changes: 33 additions & 4 deletions benchmarks/pyathena_bench/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@
)
# The flat projection with one column of Athena's json type, NULL where details is NULL.
JSON_COLUMNS = f"{FLAT_COLUMNS}, CAST(details AS JSON) AS details_json"
# The flat projection with temporal columns derived from the epoch microseconds of
# "timestamp"; from_unixtime() returns timestamp(3). UNLOAD and CTAS write Parquet
# through Hive, which has no time or with time zone types, so their reads project
# the timestamp and date columns only. The zone has no daylight saving time, whose
# ambiguous local times PyAthena rejects.
_SECONDS = '"timestamp" / 1e6'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round two, evidence for the claims:

Rerun on Athena after the zone change (two-row VALUES queries, test work group, probe objects removed):

  • from_unixtime(double) returns timestamp(3).
  • from_unixtime(double, zone) returns timestamp(3) with time zone.
  • UNLOAD and CTAS accept event_time and event_date and reject the other four columns.
  • All six columns, with Asia/Tokyo, read without errors with Cursor, PandasCursor, ArrowCursor, PolarsCursor, and Wrangler CSV.
  • PandasCursor, ArrowCursor, and PolarsCursor read UNLOAD results of the Parquet projection.

Existing callers: the signature change of select_sql() reaches only runner.run() and aws.prepare_fixtures(), and both pass the transport. Queued fleet jobs carry CLI arguments, not SQL, and workers run the stack's checked-out commit.

Operator view: the temporal columns are computed from "timestamp", which the flat projection already reads, so a query scans no extra columns of the snapshot. The shape adds cases only when --shape temporal is selected; plan shows 138 cases at small, the same count as flat.

Evidence: the transport validation was mutation-checked (with it removed, test_temporal_projection_... fails).

Limits:

PARQUET_TEMPORAL_COLUMNS = (
f"{FLAT_COLUMNS}, from_unixtime({_SECONDS}) AS event_time, "
f"CAST(from_unixtime({_SECONDS}) AS date) AS event_date"
)
TEMPORAL_COLUMNS = (
f"{PARQUET_TEMPORAL_COLUMNS}, "
f"from_unixtime({_SECONDS}, 'UTC') AS event_time_utc, "
f"from_unixtime({_SECONDS}, 'Asia/Tokyo') AS event_time_zone, "

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one (behavior and implementation), base 7b2a084, initial head 1bf7f02, repaired head 7e6349b.

Covered: select_sql() and its callers (runner trial payloads, prepare_fixtures()), matrix() for every suite, the case/job IDs, the docs, and the new tests. Passes: my own read, then /code-review (high), then /simplify (four angles).

Result: FINDINGS, repaired.

  1. The zone-name column used America/New_York. Athena writes zone-name values without an offset, and PyAthena raises DataError for an ambiguous local time. Checked on Athena: from_unixtime(1793511000, 'America/New_York') gives DataError ... '2026-11-01 01:30:00.000 America/New_York' is ambiguous. Any snapshot that includes a fall-back hour would turn every temporal CSV case into a failure. Repaired in 64a8887: the column now uses Asia/Tokyo, which has no DST. README and DATASET.md were updated too.

f"from_unixtime({_SECONDS}, '+09:00') AS event_time_offset, "
f"CAST(from_unixtime({_SECONDS}) AS time(3)) AS event_clock"
)


def identifier(value: str) -> str:
Expand Down Expand Up @@ -139,23 +156,35 @@ def load(cls, path: Path) -> Settings:
)


def select_sql(database: str, table: str, shape: str) -> str:
def select_sql(database: str, table: str, shape: str, transport: str) -> str:
"""Return the measured query of a shape over a snapshot table.

Args:
database: Database of the snapshot.
table: Snapshot table.
shape: ``flat``, ``nested``, or ``json``.
shape: ``flat``, ``nested``, ``json``, or ``temporal``.
transport: ``csv``, ``unload``, or ``ctas``. The ``temporal`` shape
projects only the columns that Parquet can hold for ``unload`` and
``ctas``.

Returns:
The SELECT statement without a ``LIMIT``.

Raises:
ValueError: If the shape is unknown.
ValueError: If the shape or transport is unknown.
"""
columns = {"flat": FLAT_COLUMNS, "nested": NESTED_COLUMNS, "json": JSON_COLUMNS}.get(shape)
columns = {
"flat": FLAT_COLUMNS,
"nested": NESTED_COLUMNS,
"json": JSON_COLUMNS,
"temporal": TEMPORAL_COLUMNS,
}.get(shape)
if columns is None:
raise ValueError(f"Unknown shape: {shape}")
if transport not in {"csv", "unload", "ctas"}:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round one, findings 2-3 (repaired):

  1. select_sql() treated any transport outside {unload, ctas} as CSV, so a misspelled transport would read the full temporal projection. Repaired in 64a8887: an unknown transport raises ValueError. The test is in test_temporal_projection_of_parquet_reads_has_no_time_or_time_zone_types.
  2. Simplifications from my own pass and /simplify, in 0a96670 and 7e6349b: the shape lookup now runs before the Parquet override, which drops the str | None annotation. After validation, the override checks transport != "csv". The matrix() docstring no longer repeats the projection rule from select_sql().

Deferred, with reasons:

  • Precision 6 for the CSV columns: Benchmark the temporal type conversion changes of 4.2.0 #1287 specifies timestamp(3), the precision of Hive tables.
  • One table for both Parquet-incompatible mechanisms (json marked unsupported vs temporal narrowed): json without its column would repeat flat, while the narrowed temporal projection still has timestamp(3) and date. Unifying them would be an unapproved restructuring.
  • A shared TRANSPORTS constant: the transport literals are also repeated in existing code outside this diff (__main__.py:100, runner.py:557, cases.py).
  • CSV vs UNLOAD reading different column sets in the temporal shape: documented in README; the report already says to compare cases with the same transport.

raise ValueError(f"Unknown transport: {transport}")
if shape == "temporal" and transport != "csv":
columns = PARQUET_TEMPORAL_COLUMNS
return f'SELECT {columns} FROM "{identifier(database)}"."{identifier(table)}"'


Expand Down
1 change: 1 addition & 0 deletions benchmarks/pyathena_bench/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ def run(
resources["ScratchDatabase"],
manifest["scales"][scale]["table"],
case.shape,
case.transport,
),
expected_rows=manifest["scales"][scale]["rows"],
fixture=manifest["fixtures"].get(f"{scale}-{case.shape}-{case.transport}"),
Expand Down
44 changes: 41 additions & 3 deletions benchmarks/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ def test_json_shape_covers_pandas_and_wrangler_reads():
assert matrix(Settings(), "concurrent", "json") == []


def test_temporal_shape_covers_every_flat_case_in_the_single_query_suite():
# UNLOAD and CTAS read a Parquet projection instead of being unsupported.
assert matrix(Settings(), "single", "temporal") == [
replace(c, shape="temporal") for c in matrix(Settings(), "single", "flat")
]
assert matrix(Settings(), "init", "temporal") == []
assert matrix(Settings(), "concurrent", "temporal") == []


def test_concurrency_and_init_matrices_are_bounded():
cases = matrix(Settings(), "concurrent", "flat")
assert {c.concurrency for c in cases} == {1, 10, 50, 100}
Expand Down Expand Up @@ -127,17 +136,46 @@ def test_filesystem_section_is_loaded_with_its_own_scales(tmp_path):


def test_flat_projection_and_nested_projection_share_the_snapshot():
flat = select_sql("scratch", "b_run_small", "flat")
nested = select_sql("scratch", "b_run_small", "nested")
flat = select_sql("scratch", "b_run_small", "flat", "csv")
nested = select_sql("scratch", "b_run_small", "nested", "csv")
assert flat.endswith('FROM "scratch"."b_run_small"')
assert nested.endswith('FROM "scratch"."b_run_small"')
assert "file.filename AS filename" in flat
assert "project, file, details, http" in nested
assert "LIMIT" not in flat
json = select_sql("scratch", "b_run_small", "json")
json = select_sql("scratch", "b_run_small", "json", "csv")
assert json == flat.replace(
' FROM "scratch"', ', CAST(details AS JSON) AS details_json FROM "scratch"'
)
# The other shapes read the same projection with every transport.
for shape in ("flat", "nested", "json"):
for transport in ("unload", "ctas"):
assert select_sql("scratch", "b_run_small", shape, transport) == select_sql(
"scratch", "b_run_small", shape, "csv"
)


def test_temporal_projection_of_parquet_reads_has_no_time_or_time_zone_types():
flat = select_sql("scratch", "b_run_small", "flat", "csv")
parquet = flat.replace(
' FROM "scratch"',
', from_unixtime("timestamp" / 1e6) AS event_time, '
'CAST(from_unixtime("timestamp" / 1e6) AS date) AS event_date FROM "scratch"',
)
# Athena's UNLOAD and CTAS fail with "Unsupported Hive type" for time and with
# time zone columns.
assert select_sql("scratch", "b_run_small", "temporal", "unload") == parquet
assert select_sql("scratch", "b_run_small", "temporal", "ctas") == parquet
assert select_sql("scratch", "b_run_small", "temporal", "csv") == parquet.replace(
' FROM "scratch"',
", from_unixtime(\"timestamp\" / 1e6, 'UTC') AS event_time_utc, "
"from_unixtime(\"timestamp\" / 1e6, 'Asia/Tokyo') AS event_time_zone, "
"from_unixtime(\"timestamp\" / 1e6, '+09:00') AS event_time_offset, "
'CAST(from_unixtime("timestamp" / 1e6) AS time(3)) AS event_clock FROM "scratch"',
)
# A misspelled transport fails instead of reading the projection of CSV results.
with pytest.raises(ValueError, match="Unknown transport"):
select_sql("scratch", "b_run_small", "temporal", "UNLOAD")


@pytest.mark.parametrize("family", ["cursor", "dict", "arrow"])
Expand Down
19 changes: 19 additions & 0 deletions benchmarks/tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,25 @@ def summarize(s3, location):
)


def test_temporal_trials_query_the_projection_of_their_transport(monkeypatch, tmp_path, run_stubs):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round one, test coverage: test names, mutation checks, and validation results.

Test names: renamed in 64a8887 from ..._query_no_time_zone_columns, which promised more than it checked. This test covers the wiring of run(). The exact Parquet projection is asserted in test_config.py.

Mutation checks on 1bf7f02:

  • With the temporal branch of select_sql() disabled, test_temporal_projection_... fails.
  • With the runner passing "csv" instead of case.transport, this test fails.

Validation at 7e6349b:

  • just benchmark lint: passed.
  • just benchmark test: 251 passed, 1 skipped.
  • just docs lint: passed, at 64a8887; 7e6349b changes no Markdown.

payloads = []
monkeypatch.setattr(
"pyathena_bench.runner.supervise",
lambda payload, events, settings: payloads.append(payload) or {"status": "ok"},
)
monkeypatch.setattr("pyathena_bench.runner.output_files", lambda *args: {})
cases = [
Case("arrow", transport=transport, output="native", shape="temporal")
for transport in ("csv", "unload")
]
settings = replace(Settings(), warmups=0, repetitions=1)
assert run(settings, RUN_MANIFEST, cases, ["small"], tmp_path / "run")
csv, unload = (p["sql"] for p in payloads)
assert "AS event_time_utc" in csv
assert "AS event_date" in unload
assert "AS event_time_utc" not in unload


def test_filesystem_trials_transfer_the_prepared_object_within_the_scratch_bucket(
monkeypatch, tmp_path, run_stubs
):
Expand Down
Loading