Skip to content

Let DuckDB write the JSON export, and stop sorting it (~3x faster) - #12

Merged
gaurav merged 12 commits into
mainfrom
faster-json-export
Aug 20, 2026
Merged

gaurav merged 12 commits into
mainfrom
faster-json-export

Conversation

@gaurav

@gaurav gaurav commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes the JSON export ~3x faster by deleting the two things the last run's progress log pointed at: a global sort that had to finish before a single row could be written, and a single-threaded Python serialization loop that then did all the work on one core. Closes #8.

What the log showed

From the 2026-08-05 full-corpus run (40,923,261 documents, 18m 06s):

  • The first progress line landed at elapsed 3m 00s with exactly 5,000 documents — one fetchmany(batch_size=5000). Nothing had been written for three minutes because ORDER BY la.pmid must materialize and sort all 40.9M rows first. Reproduced locally on 5M rows: first batch at 0.02s without the sort, 2.04s with it.
  • RSS sat flat at 161 GiB through the write phase while peak RSS was 201 GiB — the peak belongs to the sort, not the writer.
  • Instantaneous throughput decayed 62k → 36k docs/s across the run (the cumulative 39k hides this). Sorted by ascending PMID, later records are newer and far more likely to carry an abstract, so bytes-per-document rise as it goes. Seven of eight cores were idle throughout.

What changed

DuckDB writes the JSON — one COPY (...) TO <dir> (FORMAT JSON, PER_THREAD_OUTPUT true) instead of a fetchmany loop calling json.dumps per row, so serialization runs in C++ on every thread. The query no longer sorts.

Measured end-to-end on a 2M-document copy of the database (same machine, same query, --shards 8):

Wall time Rate
Python loop + ORDER BY 112.9s 17.7k docs/s
COPY, no sort 35.5s 56.2k docs/s

The two outputs are identical: 1,995,600 rows each, and EXCEPT in both directions over the full record set returns nothing. (The new files are ~1.5% smaller — DuckDB writes compact JSON where json.dumps defaults to ", " / ": " separators.)

Keeping the record shape honest

_JSON_FIELDS — output field name → SQL expression, in emitted order — is now the single definition of a document, and the COPY projection is built from it. So the DocumentMetadataAPI names, the empty-string-not-null rule and the field order are written down once. validate imports JSON_FIELDS instead of probing _document's arity, which deletes that hack.

month_to_abbrev and _year_from_medline_date stay as Python, because validate normalizes the efetch side with them. Their SQL twins (_PUB_MONTH_SQL, _PUB_YEAR_SQL) are built from the same _MONTH_ABBR / regex and pinned to the Python by two tests over the cases an index-based lookup gets wrong ("0", "13", "99999999999999999999", "Sept", "SEPTEMBER", whitespace, None). Both were mutation-tested: dropping the capitalization from the month key, or loosening the year regex to ([0-9]{4}), fails them.

One divergence got through that pinning and is fixed here: SQL trim(x) strips spaces only, where Python's .strip() strips all whitespace. So "\tMar", "3\n" and "\n3" each gave Mar from month_to_abbrev and "" from _PUB_MONTH_SQL. That is reachable, not theoretical — parse._raw_pubdate stores findtext("Month") verbatim, so a <Month> element spanning a line arrives here intact, and the record would then export a blank pub_month and be reported as a mismatch against efetch, which is a CORE_FIELDS error. The test's only whitespace case was " 3 ", spaces alone — exactly what bare trim() already handled. Tab, newline, CR and mixed cases added.

Three behaviour changes to know about

  • --shards N is a maximum, not a count — and it is now the write parallelism. Output is one file per writer thread, so --shards caps that statement's threads (restored afterwards) and a small dataset can be written by fewer. Default is DuckDB's own thread count. On Slurm it must match --cpus-per-task, so config.sh derives SHARDS from SLURM_CPUS_PER_TASK rather than leaving the two to be kept in step by hand — a fixed 16 against 04-export.sbatch's 8 CPUs would oversubscribe 2:1 on the one step that gets OOM-killed. test_shards_tracks_the_export_allocation reads --cpus-per-task out of the sbatch header and fails if the fallback disagrees with it.
  • Shard names lost their zero padding. pubmed_metadata_00000.ndjson is now pubmed_metadata_0.ndjson.gz: PER_THREAD_OUTPUT names files from FILENAME_PATTERN '{i}', which emits a bare index. Anything globbing pubmed_metadata_* is fine; anything matching the padded form, or relying on the files sorting lexically past nine, is not.
  • validate --sample-size is per shard, so the sample now moves with the allocation. It always was per shard, but the count was fixed at 16; with one shard per writer thread, halving the export's --cpus-per-task halves how many records the next validate compares against Entrez. Documented in --help and slurm/README.md rather than changed — making the flag a total means reworking check_structure's per-shard reservoir sampling, which is validate's contract rather than this branch's subject. The report already names what a run checked (240 records sampled: 15/shard x 16 shards).
  • Shards are gzipped by default (pubmed_metadata_0.ndjson.gz); --no-gzip opts out. NDJSON compresses ~4-5x, so a full corpus lands at roughly 12 GiB rather than 52 — less written by the export, less read back by validate, less kept around. Compression happens as each shard is written, costing CPU (which this PR just freed up) rather than a second pass. Safe only because nothing downstream needs telling: find_shards already matched both extensions, and check_structure reads through a raw handle so its byte-progress denominator is the compressed size either way. test_cli_export_then_validate_needs_no_flags runs export then validate with no flags on either to keep that true.
  • Records are no longer in PMID order. Nothing downstream consumes the order — the ingest is an ElasticSearch bulk load, and validate builds its own sorted PMID manifest.

Also: DuckDB appends to a per-thread output directory rather than clearing it, so a shorter run would have left the previous run's shards behind to be read as current. The export now deletes its own pubmed_metadata_* files first (tested).

Progress output

Per-batch progress lines go with the Python loop. A heartbeat logs output size and current RSS once a minute in their place — no ETA, since the total output size isn't known until it's written:

INFO pubmed2db.export: starting JSON export: 40923261 document(s) to at most 16 shard(s) in data/json
INFO pubmed2db.export: writing: 22.4 GiB across 16 shard(s) · 187 MiB/s · elapsed 2m 03s · RSS 143.1 GiB
INFO pubmed2db.export: exported 40923261 documents to 16 shard(s) in data/json in 5m 12s (peak RSS 88.0 GiB)

(Shapes, not measurements — see below.) -v additionally enables DuckDB's own progress bar.

The heartbeat is the only thing standing between an operator and twenty silent minutes, so it is written not to fail: it stats shards that DuckDB is concurrently writing, skipping any that vanish between the glob and the stat, and the loop logs and continues on anything else rather than letting an exception kill the thread. It also starts after the statement that reads the connection's thread count — started before it, a raise there would leave a non-daemon thread looping forever and hang the CLI instead of reporting the error.

The exported ... in <duration> figure covers the whole export, including materializing _latest_snapshot — a window function over the entire article table, and plausibly the dominant phase now the sort is gone. slurm/README.md tells operators to size --time from that line, so a clock started after it would understate the job.

Verification

  • 198 tests pass, 1 skipped (the /proc current-RSS check, which skips on macOS), on both CI Pythons — 3.11 and 3.14. Main was merged in partway through, which is where the tests workflow, the Slurm pipeline and the DuckDB --memory-limit work arrived from (Add a Slurm sbatch pipeline, narrate the long-running steps, and cap DuckDB with --memory-limit #9).
  • The record-content tests were left untouched and still pass — the SQL projection produces the same documents as _document did, including identifier ordering, the month abbreviation and the MedlineDate year recovery.
  • New tests cover the shard cap being a maximum, the thread setting being restored, stale shards from a previous run being removed, the heartbeat line rendering, non-ASCII reaching the file as UTF-8 rather than \uXXXX escapes, and the export→validate round trip with no flags on either command.
  • validate's fixture is now a gzipped export, so its whole suite runs against what the CLI actually writes (and exercises appending a second gzip member to a shard).

Not verified

The new peak RSS and wall time on the cluster. The 3x is a laptop measurement on a 2M-document database; the full corpus is 20x that and the machine is different. Two things to record from the first real run:

  1. Peak RSS. The sort was the peak-memory event, so 04-export.sbatch's --mem is probably now over-provisioned — but by how much is a measurement, and under-requesting is an OOM kill several minutes in.
  2. Wall time, which decides whether the header's --time is still generous.
  3. The gzip default's CPU cost, which has never been measured at all — it happens on cores this PR freed up, but that is an argument, not a number.

Tracked in #42. The #SBATCH headers are what to edit once the numbers exist; slurm/README.md carries the run history and deliberately does not restate the allocation.

What the merge from main changed

main gained the Slurm pipeline and CI (#9) while this branch was open, and the merge was not purely mechanical. Three things a reader would otherwise have to reverse-engineer from the merge commit:

  • The orientation file moved. main split CLAUDE.md into AGENTS.md; this branch had kept editing CLAUDE.md. Git flagged no conflict — AGENTS.md arrived as a clean new file — so this branch's four decisions (the COPY writer, the gzip default, the absent ORDER BY, EXPECTED_FIELDS from JSON_FIELDS) would have vanished silently. They are ported into AGENTS.md.
  • ID_PREFIXES now says PMCID, not PMC. That is main's deliberate bet from Settle the CURIE prefix for PMCIDs in the JSON export's identifiers #33, which this branch predated. It changes every PMCID CURIE in the export, and it arrived through the merge rather than through either PR's own work — worth knowing when reading the identifier diff. The CASE is derived from ID_PREFIXES rather than hand-written, which is pinned by a test.
  • Four of main's tests asserted the pre-COPY shape — zero-padded shard names and plain-.ndjson reads that the gzip default breaks. Rewritten against the properties, using the gzip-aware helpers this branch already had.

Follow-on work

  • Re-baseline the JSON export's peak RSS and wall time after the COPY rewrite #42 — re-baseline the export's --mem and --time from a cluster run, and measure the gzip default's CPU cost for the first time. The only item here that cannot be settled without hardware.
  • FUTURE.md, "Confirm the Node Annotator JSON contract" — upgraded from eventually to before the next production export. Two artifact changes are invisible to the downstream ElasticSearch ingest, which lives outside this repo: the gzip default renames every shard, and an ingest that globs *.ndjson without decompressing reads nothing — an empty ingest rather than an error, which is the worst shape a failure can take. --no-gzip restores the old artifact if the answer turns out to be no.

🤖 Generated with Claude Code

gaurav and others added 2 commits August 5, 2026 12:55
The export spent ~80% of its wall time serializing documents in a single
Python loop while seven cores idled, behind a full ORDER BY la.pmid that had
to materialize all 40.9M rows before the first one could be written — ~3
minutes of an 18-minute run, and the job's peak-memory event.

Both are gone. The export is now one COPY ... (FORMAT JSON,
PER_THREAD_OUTPUT), so the serialization runs in C++ across every thread, and
the query has no ORDER BY (issue #8): shard membership no longer depends on
scan order, since each writer thread owns a file, and nothing downstream
consumes PMID order — the ingest is an ElasticSearch bulk load and validate
sorts its own manifest.

On a 2M-document copy of the database, same machine, same query: 112.9s ->
35.5s (17.7k -> 56.2k docs/s), with byte-identical record sets (EXCEPT in
both directions over all 2M rows returns nothing).

_JSON_FIELDS — output name to SQL expression, in emitted order — becomes the
single definition of a document, so the spec's field names, the
empty-string-not-null rule and the field order are written down once; validate
imports JSON_FIELDS instead of probing _document's arity. month_to_abbrev and
_year_from_medline_date stay as Python because validate still normalizes
efetch's side with them, and their SQL twins are pinned to them by tests over
the cases an index-based lookup gets wrong ("0", out of range, "Sept",
whitespace) — a divergence there would make every normalized record read as a
PubMed mismatch.

Two consequences worth knowing:

- --shards N is now a maximum rather than a count. One file per writer thread
  is what PER_THREAD_OUTPUT gives, so it caps that statement's thread count
  (restored afterwards) and a small dataset can use fewer.
- DuckDB appends to the output directory rather than clearing it, so the
  export deletes its own pubmed_metadata_* files first — otherwise a shorter
  run leaves a previous run's shards to be read as current.

Per-batch progress lines go with the Python loop; a heartbeat logs output size
and current RSS once a minute in their place, which is what sizes the next
--mem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the 2026-08-05 full-corpus run (40,923,261 documents, 18m 06s, peak
RSS 201.0 GiB) and marks all three measured runs as predating the rewrite:
they are the numbers to beat, not the numbers to request. The two figures to
read off the next cluster run are called out, since neither follows from a
laptop benchmark — peak RSS especially, which is what decides whether
--mem=256G can come down now that the sort is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Python writer said `ensure_ascii=False` out loud; DuckDB's JSON writer does
the same by default, which means nothing in the repo would notice if that ever
changed. One test now asserts the bytes on disk, since a consumer reading an
escaped code point where it expects the accented character is exactly the kind
of break a passing suite would hide.

Also notes in CLAUDE.md that PARTITION_BY (pmid % shards) — the obvious way to
get an exact shard count back — is rejected by DuckDB <= 1.5.4 for FORMAT JSON,
so the next person doesn't spend the same twenty minutes finding out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NDJSON compresses ~4-5x, so a full corpus goes from ~52 GiB to roughly 12 —
less to write, less for the next validate to read back, and less to keep
around. Compression happens as each shard is written, so it costs CPU (which
the COPY rewrite freed up) rather than a second pass.

This is only a safe default because nothing downstream has to be told: the
report-reading side already matched .ndjson and .ndjson.gz alike, and
validate's byte-progress denominator is the compressed size either way since
it reads through a raw handle. test_cli_export_then_validate_needs_no_flags
runs both commands with no flags to keep that true; the validate suite's
fixture is now a gzipped export for the same reason, which also exercises
appending a second gzip member to a shard.

--no-gzip keeps the old behaviour, and is what the CLI test now covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav added a commit that referenced this pull request Aug 19, 2026
…em as JSON and Parquet (#1)

Adds **pubmed2db**: a `uv`/`click` tool that downloads every PubMed
abstract, loads it into a DuckDB database that keeps **full version
history**, and exports the **latest** version of each abstract to JSON
(DocumentMetadataAPI field names, for Node Annotator / ElasticSearch)
and Parquet (PubMed field names, for downloadable queries).

The eventual goal is to replace the PubMed download in Babel
(`createcompendia/publications.py`).

## The pipeline

Four independent commands, plus two that read or chain them:

- **`download`** — bulk baseline/update fetching via
[`cthoyt/pubmed-downloader`](https://github.com/cthoyt/pubmed-downloader),
with each file's published `.md5` checksum recorded in a `source_file`
registry. A new or changed checksum is what marks a file for
(re)loading; verification is on by default and hashes only files that
are new or whose published checksum moved (`--no-verify` opts out), and
discards a file that fails twice rather than leaving it for `load` to
pick up.
- **`journals`** — refreshes the journal dimension from the NLM Catalog
(`J_Entrez`), joined on `nlm_catalog_id`. Re-fetched on every run, since
the catalog changes and the file is small.
- **`load`** — parses the XML and inserts it, keeping every version.
Rows carry their `source_file` provenance and a `file_order_key` that
reproduces PubMed's chronological ordering, so the `latest_article` view
can pick the newest non-deleted version of each PMID (honouring
`<DeleteCitation>`).
- **`export`** — sharded NDJSON (DocumentMetadataAPI names, empty
strings rather than nulls, `pub_month` as a 3-letter abbreviation) or
one Parquet file per table (latest version by default, `--all` for full
history). Both formats sweep files left by a previous export — shards a
wider run wrote, or a Parquet file whose table left the schema — so a
consumer globbing the directory can't read two exports at once.
- **`status`** reports the state of all of it read-only; **`update`**
chains download → journals → load for scheduled runs, and treats a
failed journal refresh as non-fatal so an NLM outage can't discard a
completed download.

## Design decisions

- **The database uses PubMed's own field names.** DocumentMetadataAPI
names are applied only in the JSON export, so the store stays faithful
to the source and the API contract lives in one place.
- **Full version history, not upsert.** Every file's rows are tagged and
kept; `latest_article` derives the current view. This makes a re-load
idempotent and a corrected file cheap to apply.
- **Publication dates keep full fidelity.** We drive the XML iteration
ourselves rather than using the upstream process pipeline, because it
collapses partial and `MedlineDate`-only dates into a `datetime.date`.
Raw `Year`/`Month`/`Day`/`MedlineDate` are stored, and the JSON export
recovers a year from `MedlineDate` shapes where the parsed fields are
empty.
- **Nothing is skipped silently.** The parser drops three kinds of
record — ones the extractor rejects, ones it returns `None` for (an
empty `<ArticleTitle>`, a missing `<MedlineJournalInfo>`), and
`<PubmedBookArticle>` citations, which the DTD allows in these files but
we don't parse. None of them become rows, so no downstream count is
short; `source_file.n_failed` and `n_book_records` make them queryable
and `status` reports the total.
- **No citation graph.** One real article carries ~444 references, so at
corpus scale `reference_citation` would have been the largest table
here, for data no consumer asked for. The extraction is parked
(uncalled) with re-enabling instructions rather than deleted.
- **Step ordering is enforced from database state, not a run flag.**
`load` errors when nothing is downloaded, `export` errors when nothing
is loaded and warns about unloaded files or an empty journal table.
Deriving readiness from the registry's watermarks means a check can't
disagree with the data it guards.
- **Columnar bulk load.** Each file's rows go in as an Arrow table via
`INSERT ... SELECT` rather than row-by-row `executemany`, which took ~20
minutes per file. The benchmark measures ~5–6 s per file, but a
corpus-scale load has since been observed at ~91 s per file, so treat
the benchmark figure as a floor until #11 re-measures it.
- **MD5 is treated as low-priority insurance.** HTTP downloads are
reliable and PubMed files are immutable, so verification only hashes
files that are new or whose published checksum moved — re-hashing an
unchanged corpus costs tens of GiB of I/O and catches nothing.
- **`schema.sql` only ever adds**, and runs on every connect: `CREATE
TABLE IF NOT EXISTS` plus explicit `ALTER TABLE ... ADD COLUMN IF NOT
EXISTS` migrations for anything added after the first release. Nothing
drops a table or column, which is why a schema change wants a rebuild
rather than `load --force` (below).

## Upstream bugs worked around

Three `pubmed-downloader` (≤ 0.0.14) bugs, each tracked in `FUTURE.md`
with a pinning test so the workaround fails loudly once upstream fixes
it. The dependency is pinned `<0.1` because we also call private APIs
(`_extract_article`, `_ensure_urls`).

- **Journal parsing raises.** `catalog.process_journal_overview()`'s
`Journal` model requires `start_year`/`end_year`, which the real
`J_Entrez.txt` does not carry, so we parse the overview file ourselves.
Filed upstream as cthoyt/pubmed-downloader#16
- **References are never found.** `_extract_article` looks for
`.//ReferenceList/Reference` under `MedlineCitation`, but PubMed nests
`<ReferenceList>` under `<PubmedData>`, so `Article.cites_pubmed_ids` is
always empty on real data. Harmless here — we store no citation graph —
but a working counter-example is kept for whenever it is reported.
- **Article IDs are over-collected.** That same `.//` descent attributes
every *cited* reference's DOI/PMID to the citing article, which would
have been silently wrong rows in `article_id` proportional to reference
count. We use the direct `PubmedData/ArticleIdList/ArticleId` path
instead.

## Operations

- Everything runs from the repo with `uv run pubmed2db …`; nothing is
installed.
- Group-level `--threads` / `--temp-dir` cap DuckDB's thread pool and
set its spill directory, since DuckDB otherwise sizes its pool from the
node's cores rather than the Slurm allocation. All four group options
also read an environment variable (`PUBMED2DB_THREADS`,
`PUBMED2DB_DUCKDB_TEMP_DIR`, `PUBMED2DB_DATA_DIR`, `PUBMED2DB_DB`),
which `--help` documents.
- [`slurm/README.md`](slurm/README.md) sizes both jobs: `load` ~16 GB
(memory is bounded by the largest single file) and `export` ~256 GB
(whole-corpus snapshot + sort). The export figure comes from the
full-scale run below; the `load` figure comes from the benchmark, and a
real run has since exceeded it (#11).
- **Re-running after a gap can't duplicate data**, and the README
explains why. Two cases cost real time: a new baseline year is a full
re-parse that stores a second version of every PMID, and applying a
parser or schema change to already-loaded data. For the latter the
README now gives the rule — **rebuild from a fresh database unless you
know a parsing change is all you need**. Both cost one full `load` and
neither re-downloads, but since `schema.sql` only ever adds, a table or
column removed from it keeps its rows through any number of `load
--force` runs, and a forced reload leaves wrong rows in place for files
it re-parses identically. Only the rebuild is guaranteed to leave the
shape the schema describes.
- **`export` publishes in place, not atomically** — a run that dies
partway leaves a half-written dataset in `--out`. Export into a fresh
directory and swap it if consumers read the output while exports run
(#24).
- Documentation is split by audience: `README.md` for running the
pipeline, `AGENTS.md` for a map of the source and the decisions that
aren't visible from it, `FUTURE.md` for deferred work. Anything a
docstring or a schema comment can carry lives there instead.

## Verification

- **84 tests pass** with no network: parse fidelity, latest-version and
deletion logic, idempotent and MD5-triggered reloads, journal parsing,
the JSON field contract, Parquet filtering, DuckDB tuning options,
schema migration, and the CLI end to end.
- **Full-scale run** on RENCI's cluster: the complete corpus exported as
**40,901,984 documents** across 16 NDJSON shards in **23m13s** (≈30k
documents/s), peak RSS 201.1 GiB under `--mem=256G --cpus-per-task 8`.
- **Single-file live run**: MD5-verified download → 16,664 articles + 56
deletions → 41,923 journals → 16,664 documents across even NDJSON shards
(zero nulls, journal names and abbreviations resolved, months
normalized) and 15 consistent Parquet files.
- **Two fields a validation run reported as blank are not a parser
gap.** PMID 10137601's `issue` ("Suppl") and PMID 28972331's
`article_title` ("[Not Available].") were fetched from Entrez and run
through parse → load → export: both come out correct at every stage.
They are in the fixtures and pinned, because both shapes — a non-numeric
issue whose volume repeats it, and a title that is a literal placeholder
with the real one in `<VernacularTitle>` — are what a future change
might reasonably decide to drop.

## Follow-on work

Issues opened for what this PR deliberately leaves out:

- #8 — drop the JSON export's global `ORDER BY` (measured: the sort is
~0.8 GiB and 75% of wall time on a 2M-row sample). Being done in #12.
- #11 — a corpus-scale `load` ran ~15x slower than `slurm/README.md`
documents, and above its stated memory ceiling; the load figures need
re-measuring against a large database rather than a short benchmark.
- #21 — `needs_load` never looks at the filesystem, so a file replaced
on disk is only picked up by `load --force`.
- #22 — nothing prunes a superseded baseline year; `status` reports the
condition and the README gives the `rm`, but 51 GB of baseline plus 13
GB of updates per year wants a command.
- #23 — a smoke test for documented paths, after three drifted doc
claims.
- #24 — neither export publishes atomically.
- #25 — `parse_file` holds the whole DOM (~1.2 GiB of the ~2.2 GiB
per-file peak); `iterparse` measures at 0.02 GiB.
- #26 — parse and insert never overlap, though DuckDB releases the GIL:
~40 minutes of a full load.
- #27 — decide whether to parse `<PubmedBookArticle>` records; the
per-file count this PR records answers whether it matters.
- #28 — confirm the `pub_year` backfill at corpus scale (verified on one
baseline file: 3,625 of 30,000 records, 127 distinct `MedlineDate`
shapes, all recovering a year).
- #29 — run the Parquet export at corpus scale; only the JSON path has
full-corpus numbers.
@gaurav gaurav added this to the 2026aug20 milestone Aug 20, 2026
Base automatically changed from minor-improvements to main August 20, 2026 08:02
Brings in the Slurm pipeline, the progress narration and the DuckDB
--memory-limit work (#9). Three resolutions needed judgment rather than a
side:

**CLAUDE.md became AGENTS.md on main**, while this branch kept editing
CLAUDE.md. CLAUDE.md is now main's `@AGENTS.md` pointer, and this
branch's four decisions — the COPY-based JSON writer, gzip as the export
default, the deliberate absence of an ORDER BY, and EXPECTED_FIELDS
deriving from JSON_FIELDS — are ported into AGENTS.md. Git flagged no
conflict for that: AGENTS.md merged clean as a new file, so those
paragraphs would have been lost silently.

**export.py takes this branch's COPY rewrite wholesale**, which retires
main's fetchmany loop along with the per-batch progress line it gained
there (the heartbeat thread covers it). But main's non-conflicting
changes to the same file had to be ported by hand, since taking the file
wholesale dropped them: `_ID_CURIE_SQL`/`_ID_TYPES_SQL` deriving the
identifier CURIEs from ID_PREFIXES rather than hand-writing them (pinned
by a test), the PMCID-not-PMC decision from #33, `pipeline_run` in the
Parquet export, and the stale-Parquet sweep.

**slurm/README.md keeps this branch's newer measurements** but drops the
runnable `srun --mem=…` block they sat in: main added
`test_readme_does_not_duplicate_the_allocations`, which forbids exactly
that. The provenance is kept in main's prose form instead. The
`--threads` note also had to be re-pointed at main's correction — DuckDB
reads the cgroup, so the export's pool was already the CPUs it asked for.

Four of main's tests were written against the pre-COPY export and now
assert the wrong shape: the zero-padded shard names (DuckDB's
FILENAME_PATTERN '{i}' emits a bare index) and plain-.ndjson reads that
the gzip default breaks. Rewritten against the properties rather than the
names, using the gzip-aware helpers this branch already had.

Full suite: 194 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Reworks JSON export for faster parallel DuckDB serialization while removing the global PMID sort.

Changes:

  • Uses DuckDB COPY with per-thread output.
  • Makes gzip default and shard count a thread cap.
  • Updates validation, tests, and documentation.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/pubmed2db/export.py Implements parallel JSON export and progress logging.
src/pubmed2db/cli.py Updates export options and defaults.
src/pubmed2db/validate.py Derives expected fields from the exporter.
tests/test_export.py Tests export behavior and SQL normalization.
tests/test_cli.py Tests new CLI defaults and round trip.
tests/test_validate.py Exercises validation against gzip shards.
README.md Documents JSON export behavior.
slurm/README.md Updates performance and operational guidance.
FUTURE.md Records completion of export optimization.
AGENTS.md Captures architectural decisions.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread slurm/README.md Outdated
Comment thread tests/test_export.py
Comment thread src/pubmed2db/export.py Outdated
gaurav and others added 5 commits August 20, 2026 04:23
The merge resolution for tests/test_export.py fused two tests and
duplicated a block. Found by a code review, not by the suite, because
every symptom still passed:

- `test_writing_progress_line_renders` kept its docstring and lost its
  body — a legal function asserting nothing, reading as coverage for the
  heartbeat's %-format while providing none.
- Its body had been grafted onto `test_export_progress_line_renders`,
  main's test for the fetchmany progress branch that the COPY rewrite
  deleted, along with a stray export_json() call. That test is now gone;
  the code path it covered no longer exists.
- `test_placeholder_looking_fields_survive_to_the_export` and
  `test_parquet_export_removes_a_dropped_table_s_file` each appeared
  twice, byte-identical, so Python kept the second and pytest collected
  one. Verified identical before dropping the copies.

The restored test is mutation-checked: an arity mismatch in the
"writing: ..." format string fails it, which is the failure it exists to
catch before a 20-minute production run finds it.

Checked the same way for lost or duplicated definitions across every
merged test file; test_cli.py's apparent losses were main's own renames
and its staged_download fixture refactor, and are correct as merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--shards` stopped being a free file-count knob when the COPY rewrite made
it the writer-thread count: export_json runs `SET threads = $SHARDS` for
the statement. config.sh still defaulted to 16 while 04-export.sbatch
requests --cpus-per-task=8, so the default cluster run oversubscribed 2:1
on the one step that gets OOM-killed, and whose new peak RSS is
unmeasured. The same SET silently raises a group-level --threads set to
run *below* an allocation on a busy node.

SHARDS now derives from SLURM_CPUS_PER_TASK, falling back to the number
the sbatch header asks for, so changing the header moves both.
test_shards_tracks_the_export_allocation holds them together and fails if
the fallback and the header disagree.

04-export.sbatch's own header still described the pre-rewrite export — a
global sort, single-threaded serialization, "--shards does not want a CPU
each" — all three of which this branch removed or inverted. AGENTS.md
makes those headers the authoritative record of an allocation, so an
operator sizing the job was reading the old model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SQL `trim(x)` strips spaces and nothing else; Python's `.strip()` strips
all whitespace. So `pub_month` values carrying a tab or a newline gave
"Mar" from `month_to_abbrev` and "" from `_PUB_MONTH_SQL`.

That is not hypothetical: `parse._raw_pubdate` stores `findtext("Month")`
verbatim with no strip, so a `<Month>` element spanning a line reaches the
export as "\nMar". The record then exports a blank pub_month *and* is
reported as a mismatch against efetch — `validate` normalizes efetch's
side with the Python function — which is a CORE_FIELDS error.

`test_month_sql_matches_month_to_abbrev` did not hold the contract
AGENTS.md claims for it: its only whitespace case was " 3 ", spaces alone.
Tab, newline, CR and mixed cases added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three robustness fixes to the COPY writer, all on the path that exists so
a 20-minute export is not silent:

The heartbeat thread started *before* `SELECT current_setting('threads')`
and outside the try. If that raised — a closed or invalidated connection
is the realistic case — `stop` was never set, and the thread is
non-daemon: the interpreter would not exit, so the CLI hung instead of
reporting the error. The read now happens first.

`_log_writing_progress` stats shards while DuckDB writes the same
directory, so a path can vanish between the glob and the stat. That
FileNotFoundError escaped and killed the heartbeat, after which the export
went completely silent — precisely the failure the heartbeat prevents.
`_shard_bytes` skips what it cannot stat, and the loop swallows anything
else with a warning.

The reported duration started after `_latest_snapshot` was materialized
and counted — a window function over the whole `article` table, which is
why export memory scales with the database, and plausibly the dominant
phase now the sort is gone. slurm/README.md tells operators to size
--time from that line, so it was understating the job. The clock now
starts before it, pinned by ordering rather than by wall-clock timing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are outside anything this repo can verify, because the consumer is.
Gzip defaulting on renames data/json/pubmed_metadata_*.ndjson to *.gz, and
an ingest that globs *.ndjson without decompressing reads nothing — an
empty ingest rather than an error, which is the worst shape a failure can
take. Shard names also lost their zero padding, since DuckDB's
PER_THREAD_OUTPUT names files from FILENAME_PATTERN '{i}'.

Folded into the existing "Confirm the Node Annotator JSON contract" item,
which already asked the gzip question, with a note that it now wants
answering before the next production export rather than eventually.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav and others added 2 commits August 20, 2026 04:32
The JSON export writes one shard per writer thread, so the shard count
follows the allocation rather than being fixed at 16. `validate` samples
--sample-size records from *each* shard, which means halving the export's
--cpus-per-task silently halves how many records the next validate
compares against Entrez.

Documented rather than changed: making the flag a total would mean
reworking check_structure's per-shard reservoir sampling, which is
validate's contract rather than this branch's subject. The report already
prints what a run actually checked ("240 records sampled: 15/shard x 16
shards"), so the coupling is visible per-run once you know to look — this
says so in --help and in slurm/README.md.

Also re-points the export's memory/time prose at the #SBATCH headers
rather than restating 256G and 02:00:00, which the merge from main left
as a second copy of numbers that live in 04-export.sbatch. #42 tracks
re-baselining them from a real run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot: src/pubmed2db/export.py — the field documentation said Babel's
`PMC = "PMC"` and gave `PMC:PMC1234567` as the emitted example, while
ID_PREFIXES two lines down says `PMCID` and the exporter emits
`PMCID:PMC1234567`.

A merge artifact: main's "the PMCID prefix is not settled" note (#33)
landed on top of this branch's older paragraph without replacing it, so
the two sat adjacent saying opposite things — and the stale one came
first, which is the one a reader takes as the contract.

README.md was already correct (`PMCID:PMC6423490`); this was the only
place left claiming otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav
gaurav merged commit 0090952 into main Aug 20, 2026
2 checks passed
@gaurav
gaurav deleted the faster-json-export branch August 20, 2026 08:37
gaurav added a commit that referenced this pull request Aug 20, 2026
Brings in PR #12's COPY-based JSON export and the fixes that followed it.
Simpler than the last merge in this stack, because this branch was already
based on the COPY rewrite — its `_PUB_MONTH_SQL` was already SQL, not the
Python `_document` loop. Three resolutions needed judgment:

**The whitespace trim had to be woven into the generator, not pasted next
to it.** main fixed `_PUB_MONTH_SQL` to strip what Python's `.strip()`
strips (SQL `trim(x)` takes spaces only), while this branch replaced that
constant with `_normalize_month_sql(expr)`, a generator applied to two
sources — the `pub_month` column and the `MedlineDate` remainder. Taking
either side whole would have dropped the other: every `trim` in the
generated SQL now names `_WS`, including the `ELSE` passthrough this
branch added, so `"\tSep-Dec"` survives as `"Sep-Dec"` on both sides.
`test_pub_month_sql_matches_python` already iterates the cross product of
month × MedlineDate inputs; the union of both sides' cases now runs
through it, 276 combinations. Mutation-checked.

**CLAUDE.md became AGENTS.md on main again**, and again git flagged no
conflict on AGENTS.md, so this branch's three documented decisions would
have been lost silently: the approximate-month rule and its `isalpha()`
guard, the cross-product pinning, and why `validate` takes `_MONTH_ABBR`
from the exporter rather than `calendar.month_abbr`. Ported.

**FUTURE.md's `MedlineDate` item was resolved on one side and extended on
the other.** This branch closed the "pub_month is deliberately empty"
half; main added the PMCID item (#33) and an issue number to the
ELocationID one. Both kept.

`validate._VALID_MONTHS`: both sides independently replaced
`calendar.month_abbr` for the same locale reason. This branch's version is
a superset — abbreviations, seasons and ranges — and already carries
main's reasoning, so it stands.

Checked every merged file for definitions lost, duplicated, or silently
emptied — the three failure modes of the previous merge. The only names
missing are this branch's own renames (`month_to_abbrev` →
`normalize_month`/`pub_month`, and their tests), each with its replacement
present. Full suite: 214 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav gaurav mentioned this pull request Aug 20, 2026
gaurav added a commit that referenced this pull request Aug 20, 2026
#12 has landed, so this now targets `main` directly and has been merged
with it. **#17 is stacked on top of this one** — it adds the verbatim
`pub_date` field, and reading them together is what makes the `"Dec-1999
Jan"` case below read as a design rather than a wart.

Closes #14. Follow-ups: #16 (normalizing names inside a range), #17
(`pub_date`).

## The problem

The DocumentMetadataAPI spec contradicts itself. Its prose says *"Send
month names as capitalized three-letter abbreviations"*, but its **own
worked example** for PMID:8000234 emits:

```json
"pub_year": "1994", "pub_month": "Sep-Dec", "pub_day": ""
```

We were following the prose and shipping `"pub_month": ""` for every
season or range. This PR follows the example: **take the month from the
input and pass it through unchanged**, normalizing only the values that
genuinely *are* month names.

There was also a latent corruption hiding behind the gap.
`month_to_abbrev` matched on a 3-character prefix, so a value like
`"Sep-Dec"` would have been silently truncated to `"Sep"` — we only
never saw it because the season never reached the function.

## The rule

```
pub_month = normalize(Month or Season)  or  normalize(MedlineDate after its leading year)
```

The column wins when both are present (`pub_month("Mar", "1998 Spring")`
→ `"Mar"`); the `MedlineDate` is a fallback, not an override.

### Month names fold

| Input | Output | |
| --- | --- | --- |
| `"Mar"` | `"Mar"` | |
| `"3"` / `"03"` / `" 3 "` | `"Mar"` | numeric |
| `"March"` | `"Mar"` | |
| `"Sept"` | `"Sep"` | |
| `"sep"` / `"SEPTEMBER"` | `"Sep"` | case-insensitive |
| `"13"` | `""` | a number that isn't a month is garbage, not a season |

### Everything else passes through — this is the fix

| Input | Output | Before this PR |
| --- | --- | --- |
| `"Sep-Dec"` | `"Sep-Dec"` | **`"Sep"`** — silently truncated |
| `"Jul-Aug"` | `"Jul-Aug"` | **`"Jul"`** — silently truncated |
| `"Spring"` | `"Spring"` | `""` |
| `"Winter"` | `"Winter"` | `""` |
| `"Winter-Spring"` | `"Winter-Spring"` | `""` |
| `"September-December"` | `"September-December"` | `""` — see #16 |
| `"Jul-August"` | `"Jul-August"` | `""` — see #16 |
| `"Sept-Dec"` | `"Sept-Dec"` | `""` — see #16 |

### Recovered from a free-text `MedlineDate`

| Input | Output | |
| --- | --- | --- |
| `"1994 Sep-Dec"` | `"Sep-Dec"` | the spec's PMID:8000234 example |
| `"1998 Spring"` | `"Spring"` | |
| `"  2001 Winter"` | `"Winter"` | leading whitespace tolerated |
| `"1998 September"` | `"Sep"` | a long name still folds here |
| `"1998 Dec-1999 Jan"` | `"Dec-1999 Jan"` | verbatim; we don't invent
`"Dec-Jan"` |
| `"1999-2000"` | `""` | a year range has no month — **not** `"-2000"` |
| `"n.d."` / `"Spring 1998"` | `""` | no leading year; don't guess |
| `"12345"` | `""` | |

`pub_day` stays blank throughout, as the spec example has it.

## How it's built

Three commits, each green on its own:

1. **Pass approximate months through** — `month_to_abbrev` →
`normalize_month`, with an `isalpha()` guard separating "is a month
name" from "isn't"; `_month_from_medline_date` as the month-side sibling
of the existing year recovery. Its mandatory whitespace after the year
is load-bearing: it stops `"1999-2000"` yielding `"-2000"`.
2. **Read `<Season>`** — PubMed serves the same record three ways, and
we read only two. The DTD makes `<Month>` and `<Season>` mutually
exclusive, so the season shares the existing `pub_month` column — **no
schema column, no migration**.
3. **Docs** — supersedes the FUTURE.md bullet that said month/day were
deliberately blank, and records `<Season>` in the README's date bullet,
which is the one user-facing place that still listed only
`Year`/`Month`/`Day`/`MedlineDate`. A Parquet consumer reading
`article.pub_month` now sees `Winter` as readily as `Mar`.

Merging `main` added a fourth strand. The `COPY` rewrite had fixed
`_PUB_MONTH_SQL` to strip what Python's `.strip()` strips (SQL `trim(x)`
takes spaces alone), while this branch had replaced that constant with
`_normalize_month_sql(expr)` — a generator applied to two sources.
Taking either side whole would have dropped the other, so every `trim`
in the generated SQL now names `_WS`, including the `ELSE` passthrough
this branch adds.

## Verification

- `test_pub_month_sql_matches_python` iterates the **cross product** of
27 month × 15 MedlineDate inputs (405 cases) — the SQL falls through
from one source to the other, so they interact. Includes a non-ASCII
spelling, which is why the SQL uses `\p{L}+` and not `[A-Za-z]+`:
Python's `str.isalpha()` is Unicode-aware and the twins must not
disagree.
- **Real PMID 8000234** pulled from efetch and pushed through `parse →
load → export` in *both* renderings — efetch's
`<Year>1994</Year><Season>Sep-Dec</Season>` and the archival
`<MedlineDate>1994 Sep-Dec</MedlineDate>`. Both emit `1994` / `Sep-Dec`
/ `""`, reproducing the spec example.
- **`validate` against live PubMed**: `pub_month` no longer appears in
the mismatch tally. Its efetch side now reads all three renderings
through the exporter's own function — without that the fix would only
relocate the disagreement.
- 214 tests pass, 1 skipped, on both CI Pythons (3.11 and 3.14).

## Two ways the twins disagreed, both found by review

The Python/SQL pair is only worth having if it actually agrees, and the
cross-product test had blind spots in both directions. Both fixes are
mutation-checked.

**The `MedlineDate` separator meant different things in the two regex
engines.** DuckDB's RE2 reads `\s` as `[\t\n\f\r ]` — no `\v`, no
non-ASCII space — where Python's `re` includes both. So
`"1998\xa0Spring"` gave `Spring` from the Python twin and `""` from the
SQL. Because `validate._text` collapses whitespace before the efetch
side reaches `pub_month`, efetch always renders `Spring`, and the
disagreement surfaced as a `pub_month` mismatch — a CORE_FIELDS error on
a correctly-exported record, the same gating failure the `<Month>` tab
case had already caused once. `[\s\p{Z}\x0B]` tracks Python's `\s` on
every shape probed, em space and form feed included. The test could not
have caught it: every `_MEDLINE_INPUTS` entry used a plain space as the
separator.

**`normalize_month` could raise on input this PR is what widens.**
`"²".isdigit()` is `True` while `int("²")` raises, and the function's
input goes from a `<Month>` element (constrained to
`Jan`..`Dec`/`01`..`12`) to arbitrary `<MedlineDate>` CDATA. The
`ValueError` would have escaped `efetch_documents` and aborted a whole
validation run rather than being recorded as one odd record. Guarded
with `raw.isascii() and raw.isdigit()`, which is also the exact match
for the SQL twin's `[0-9]+` — `"١٢"` is both `isdigit()` and
`isdecimal()`, so `isdecimal()` alone would have left Python answering
`Dec` where the export passes `١٢` through.

## Also fixed

`validate._VALID_MONTHS` was built from `calendar.month_abbr`, which is
`LC_TIME`-dependent — exactly what `export._MONTH_ABBR` was frozen to
avoid. Under a non-English locale the `month-format` check would have
warned on every record in the export. Now built from the exporter's
tuple, and broadened to months, seasons and ranges of those, so odder
shapes like `"Dec-1999 Jan"` stay visible as warnings rather than being
blessed.

## Rollout

**A re-export is required** for this to reach consumers. A reload is
required only for `<Season>` records — and only if the archival XML
actually carries them, which is still unmeasured: every case observed so
far is the `<MedlineDate>` form in the baseline, with `<Season>` coming
only from efetch. `zgrep -c "<Season>"` over a baseline file would
settle it; noted as open in FUTURE.md.

## Deliberately not in scope

- **Normalizing month names *inside* a range** — `"September-December"`
stays verbatim rather than becoming `"Sep-Dec"`. Tracked in **#16** with
the query to decide it and the sketch to implement it. It may describe
zero records: PubMed's own convention in `<Season>`/`<MedlineDate>` is
already 3-letter.
- **`pub_day` for range dates** — the spec example wants `""`.
- **Tidying `"Dec-1999 Jan"`** — that would mean inventing a value
PubMed never wrote. It is what PubMed wrote and what its API renders
back, so the export mirrors it, and `validate` warns on it deliberately:
`_VALID_MONTHS` is a closed set that admits months, seasons and ranges
of those but not this, so cross-year shapes stay visible instead of
being blessed. **Export and checker disagreeing here is the design**,
which had been recorded only in this description — it came back as a
review finding, so it now lives in `_month_from_medline_date`'s
docstring and in `AGENTS.md` too. The real answer to the wart is a
different field, not a tidier month: **#17** adds a verbatim `pub_date`
carrying PubMed's own string, the way `esummary`'s `pubdate` does.

## Open, and worth answering before the rebuild

@shuchenliu asked on #14 whether DocumentMetadataAPI and its consumers
are comfortable receiving `"pub_month": "Dec-1999 Jan"`, and that has
not been answered. **#17's `pub_date` does not close it** — it gives
consumers a clean string to render from, which is the better answer, but
whether the odd `pub_month` is acceptable *alongside* it is a separate
yes/no from the same people.

Split out as **#43** so it does not close along with #14 when this
lands, and cross-referenced from `FUTURE.md`'s "Confirm the Node
Annotator JSON contract" list — with two other things the next
production export changes that consumers cannot see coming: shards are
gzipped by default now, and shard filenames lost their zero-padding. All
three want the same conversation, before the rebuild rather than after
it.

If the answer is "blank it instead", the change is a one-line gate in
`_month_from_medline_date` restricting the `MedlineDate` fallback to the
closed set `validate._VALID_MONTHS` already accepts — named in that
function's docstring, and cheap, but it has to be made before a
whole-corpus run rather than after.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
gaurav added a commit that referenced this pull request Aug 20, 2026
Brings in #15 (which this branch was cut from, before its review round)
plus the fixes that followed it. Two resolutions needed judgment, and one
of them was a live bug rather than a textual clash:

**`_PUB_DATE_SQL` reintroduced the divergence main had just fixed for
`pub_month`.** This branch was cut before that fix, so its new SQL used
bare `trim()` while the Python twin uses `.strip()` — and SQL `trim`
strips spaces alone. Measured, not assumed: a `MedlineDate` of
"\t1998 Spring" and a `pub_day` of "\n15" each made `pub_date` disagree
with `pub_date()`. Every `trim` in the expression now names `_WS`, and
the twins agree across all 1,680 combinations of the four date inputs.
This is the second time the same mistake has arrived through a merge, so
`AGENTS.md` now says it about `_PUB_DATE_SQL` specifically rather than
leaving it as a general rule about the month SQL.

**A branch test globbed `*.ndjson`.** Gzip became the export default in
#12, which reached main after this branch was cut, so
`test_month_format_accepts_what_the_export_can_emit` failed on
StopIteration for all nine of its cases. Switched to the gzip-aware
`_append_to_shard` helper that already exists in the file.

CLAUDE.md became AGENTS.md on main, as in the previous two merges of this
stack, and again with no conflict flagged on AGENTS.md itself. This
branch's three decisions are ported: `pub_date` as the fidelity guarantee
(including why `_PUB_DATE_SQL` calls `_normalize_month_sql` rather than
`_PUB_MONTH_SQL`), `esummary` as the rendering to consult for "what should
we emit", and PMID sampling as the way to measure corpus-wide frequency.
FUTURE.md keeps both sides' new bullets.

Checked every merged file for definitions lost, duplicated or emptied.
Two flags, both false positives: tests/test_cli.py is byte-identical to
main, and test_mismatch_kind_classification is a genuine one-line
parametrized assertion. Full suite: 231 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider dropping the global sort in the JSON export

2 participants