Data · · 5 min read

Hundreds of terabytes in Athena: partitions by the hour, and queries that do less

Alerting queries over hundreds of terabytes of Parquet on S3 hit Athena’s 240-minute limit and were cancelled. Hourly partitions, file metadata moved out of every row, and ranges that split on failure brought them to about 30 minutes.

At an Italian fintech in cybersecurity, an internal alerting service had one job: tell a subscriber when their data shows up in a new leak. The data it searched was hundreds of terabytes of Parquet on S3, queried with Amazon Athena, and Athena bills by the data each query scans. Serverless or not, the big queries never finished: they ran into Athena’s time limit and were cancelled. A query runs for 30 minutes by default, and 240 minutes is the most the limit can be raised to (AWS: Athena service quotas); ours were hitting 240. After the changes below the same queries ran in about 30 minutes.

Two directions of the same question

Alerts came from two kinds of jobs:

  • New files, existing subscribers. A fresh batch of leaked data arrives; every current subscriber has to be checked against it.
  • New subscribers, existing files. Someone signs up; everything already collected has to be checked against their data.

The first job reads a small, recent slice of the data. The second reads all of it, for a handful of people. The layout on S3 has to serve both, and as it turned out, each job was fixed by a different change.

CSV files with millions of records each were ingested by a service written in Rust, processed by analysts, and landed on S3 as Parquet. The alerting jobs queried that data, checked whether a record belonged to a subscriber, and created an alert when it did. Click a box for details, or switch to Before to see where it broke.

Alerting pipeline · data path

01 · CSV files

Leaked data arrives as CSV files with millions of records each.

Format CSV
  1. 01 · CSV files Leaked data arrives as CSV files with millions of records each.
  2. 02 · Ingest service A service written in Rust ingests every file and passes the records on.
  3. 03 · Analysts Analysts process the data before it lands on S3.
  4. 04 · S3 · Parquet Parquet on S3, partitioned by date and hour, new partitions registered with MSCK REPAIR TABLE. A job about one new batch reads the hours it arrived in.
  5. 05 · Athena queries Queries filter on partitions, read file metadata from a small table instead of parsing it on every row, and split a failing range into parts that run in parallel.
  6. 06 · Alerting jobs Two jobs: new files against existing subscribers, and new subscribers against existing files. Each checks whether a record belongs to a subscriber and creates an alert.

Partitions down to the hour

Athena reads only the partitions a query filters on. AWS’s own example shows a date filter cutting one query from 74.1 GB scanned to 29.96 MB (AWS Big Data Blog).

The data was partitioned by date; I added the hour. A job checking a new batch then reads the hours that batch arrived in, not the whole day. That helps the first job only: the second one reads every partition anyway. New partitions were registered with MSCK REPAIR TABLE, which finds new dt=…/hour=… prefixes on S3 and adds them to the catalog.

records.sql
illustrative
CREATE EXTERNAL TABLE records (  -- [columns])PARTITIONED BY (dt string, hour string)STORED AS PARQUETLOCATION 's3://[bucket]/records/'; -- after new files land on S3MSCK REPAIR TABLE records; -- a new-files job reads one hour, not the whole tableSELECT ...FROM recordsWHERE dt = '[yyyy-MM-dd]' AND hour = '[HH]';
  1. Two partition keys. Athena reads only the partitions a query filters on.
  2. Registers new dt=…/hour=… prefixes. It gets slower as partitions add up, so past a few thousand add each new one with ALTER TABLE ADD PARTITION.
  3. Filter on both keys: one hour is read instead of the whole day.

Queries that do less

Partitions do nothing for a job that reads everything. For that one, what mattered was the shape of the data and the SQL that reads it.

One row per value, and the file’s metadata on every row

Each record held a single value, such as a first name, together with the metadata of the file it came from. A CSV with millions of rows meant the same metadata copied millions of times, and there could be hundreds of metadata fields: spread over many columns, or packed into one.

Packed is worse than it sounds. Sometimes the column was a struct, which Athena (built on Presto, now Trino) reads directly. Sometimes it was the same object stored as a string, and then every query had to parse that string, row by row, before it could filter on a single field.

So the metadata moved to a table of its own: one row per file, and in the records a single file_info_id in place of hundreds of repeated values. The same query before and after, with a Diff between them:

before.sql
illustrative
SELECT valueFROM recordsWHERE dt = '[yyyy-MM-dd]'  AND json_extract_scalar(file_info, '$.source') = '[source]'; -- parsed on every row
  1. The JSON string is parsed on every row before the filter can run.
after.sql
illustrative
SELECT r.valueFROM records rJOIN file_info f ON f.file_info_id = r.file_info_idWHERE r.dt = '[yyyy-MM-dd]'  AND f.source = '[source]'; -- a typed column, read once per file
  1. File metadata has its own table, one row per file.
  2. The filter runs on a typed column of the small table.

Two things changed. The filter on file metadata now runs on a small table with one row per file, not on billions of rows, and the JSON parsing that ran on every row is gone. Then the few matching files are joined to the big table, with the small table on the right of the join, as AWS recommends (AWS Big Data Blog).

Named steps

Long nested queries became a sequence of CTEs, one named step each, so it was clear which step did the expensive work.

When a query fails, ask for less

Even with the layout fixed, a query over a long range could still hit the time limit. Instead of retrying the same query, a failed range was split into smaller ones and the parts ran in parallel: a month into four weeks, a week into days, a day into hours. The total work stays the same, but each piece fits under the limit, and running them side by side brings the wall-clock time down too. The simulation below runs that logic on a month of data (illustrative, not measured):

Split on failure · one month

Queries 0 Failed 0 Longest path none
split-on-failure.ts
illustrative
type Range = { from: Date; to: Date; unit: 'month' | 'week' | 'day' | 'hour' }; const finer = { month: 'week', week: 'day', day: 'hour' } as const; async function runRange(range: Range): Promise<void> {  try {    await runAthenaQuery(range); // one query for the whole range  } catch (error) {    if (range.unit === 'hour') throw error; // nothing smaller left    const parts = split(range, finer[range.unit]); // a month becomes weeks, a week days, a day hours    await Promise.all(parts.map(runRange)); // the parts run in parallel  }}
  1. One query for the whole range first.
  2. An hour that still fails is not split further: the error goes up.
  3. Only the failing range is split, and its parts run in parallel.

Only the part that fails gets split. A month that runs in one query stays one query; a heavy day inside it is the only one that goes down to hours.

What it changed

One long alerting query · run time
Before240 mincancelled at the limit
After~30 mincompletes

If an Athena job is slow or failing, look in this order: does the query filter on the partitions, does it parse or compute something on every row that could be done once, and does it filter the small tables before joining the big one. When a range is still too big, split it on failure rather than retrying it whole.

Contact

Questions about this?

Book a call Write to hello@paxcode.it