feature(Files): Download one file per day for the daily parquet layout - #17
Conversation
From 2026-08-01 the API returns one parquet per day rather than one per
month, and `FileInfo` gains an optional `day`. The CLI discarded that
field, so every daily file in a month resolved to the same output name:
2026-08-01 ─┐
2026-08-02 ─┼─> outputDir/2026-08.parquet
... ─┘
Up to `--max-concurrent` goroutines then raced to `os.Create` and copy
into that one path. A 31-day August left a single file holding one
arbitrary day, or an interleave of several, and the CLI still exited 0
reporting "Successfully downloaded 31 files".
Daily files are now named `2026-08-01.parquet`; monthly files keep
`2026-08.parquet`. Both parts are zero-padded so a listing sorts by date
— that is a local naming choice, not the R2 key format, which leaves the
month unpadded.
`Day` is a `*int` rather than an `int`, so an absent field stays distinct
from a literal 0. Reading a stray 0 as "monthly" would put a daily file
back on the monthly file's path.
Names are resolved and checked for duplicates before anything is written,
so a response that somehow collides fails the run instead of silently
letting the last writer win. That guard, not the naming, is what makes
this class of bug impossible to reintroduce.
Also splits one attempt out of `downloadToFile`'s retry loop as
`downloadOnce`. The loop body used `defer` for both the response body and
the destination file, and a deferred call runs at function return, not at
the end of the iteration — so every attempt's handles stayed open for the
whole retry sequence and a failed attempt could flush into a path a later
attempt had already truncated. Same corrupt-output symptom, same
function, so it is fixed here rather than left behind.
Tests: 10 new cases in daily_files_test.go, including an end-to-end run
against a stub API asserting three days produce three files with three
distinct bodies. Reverting the naming fails four of them. The five
integration tests that need APERIODIC_API_KEY fail identically on main
without it.
gofmt also realigned the pre-existing `Exchange` const block in types.go.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YW171yVjpD7c59PtLT4zKz
QA: daily parquet downloads — PASSAn August range produces one correctly-named, distinct file per day. Tested against
Setup: branch Blocking issuesNone. Nothing found here should stop #17 from merging. Deviations from the QA brief
Not the CLI's fault
Couldn't determine
Updated after checking CI: the 5 integration tests pass there. An earlier version of this comment listed them as FAIL, based on a local run with a staging-scoped key. |
What
From 2026-08-01 the API returns one parquet per day rather than one per month, and
FileInfogains an optionalday(dream-faster/unravel-router#804, now merged). The CLI discarded that field.FileInfohad noDay, soprocessor.gonamed every output from year and month alone:Every daily file in a month therefore resolved to the same path:
Up to
--max-concurrentgoroutines then raced toos.Create(which truncates) andio.Copyinto that one path. A 31-day August left a single file holding one arbitrary day's data, or an interleave of several — and the CLI exited 0 reportingSuccessfully downloaded 31 files, listing the same name 31 times.Silent, so a user has no signal that 30 days are missing.
Changes
Naming. Daily files become
2026-08-01.parquet; monthly files keep2026-08.parquet. Both parts are zero-padded so a directory listing sorts by date. That is a choice about local filenames and deliberately not a mirror of the R2 key, which writes the month unpadded — there's a comment saying so, because "aligning" them would be a natural-looking mistake.Dayis*int, notint. An absent field stays distinct from a literal0. With a plain int, a stray0from the API would read as "monthly" and put a daily file back on the monthly file's path — the exact failure being fixed.A collision guard. Names are resolved and checked for duplicates before anything is written; a colliding set fails the run with both indices and the shared name. This, rather than the naming, is what makes the bug class unreachable — any future response shape that maps two files to one path now errors instead of letting the last writer win.
Retry-loop handle leak (same function, same symptom).
downloadToFileuseddeferinside its retry loop for both the response body and the destination file. A deferred call runs at function return, not at the end of an iteration, so every attempt's handles stayed open for the whole retry sequence — a failed attempt could still be flushing into a path a later attempt had already truncated. One attempt is nowdownloadOnce, which closes what it opens and reports whether the failure is worth retrying (transport and HTTP errors yes, a local filesystem error no, preserving the previous behaviour of not retryingos.Createfailures).README. The Output section described files as "named by year and month". It now covers both granularities, the changeover date, and shows the two filename shapes.
Tests
10 new cases in
daily_files_test.go, covering naming, chronological sortability, collision rejection, optional-dayJSON decoding, and end-to-end CLI runs against a stub API.The load-bearing one is
TestCLI_DailyFilesWriteOnePerDay: three days in, three files out, each asserted to hold its own day's bytes — distinct names alone would still pass if every file held the same content. Pre-fix it produces one file.Reverting the naming fix fails four tests, including both end-to-end cases.
gofmtandgo vetclean.The five integration tests that require
APERIODIC_API_KEYfail identically onmainwithout it (requireAPIKeycallst.Fatal, nott.Skip); CI supplies the secret.Note
gofmtalso realigned the pre-existingExchangeconst block intypes.gowhile formatting the new field — three lines of whitespace, unrelated to the behaviour change.Related
day(merged)Generated by Claude Code