Skip to content

Cache the headers dict per object, collapse the six getters, and freeze the result classes (#231) - #246

Merged
kurok merged 3 commits into
masterfrom
feat/231-cache-headers
Sep 17, 2026
Merged

kurok merged 3 commits into
masterfrom
feat/231-cache-headers

Conversation

@kurok

@kurok kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Closes #231.

Branched from master (e7bbb05). Independent of #245.

Three commits, deliberately: the headers cache, then frozen, then tests/benchmarks/docs. The issue asks for frozen to be separable so a code-layout swing can be attributed to one change or the other — which, given this repo's history, turned out to be the right instinct.

What changed

Six classes expose headers — PyMail, PyMailMetadata, PyLazyMail, PyMimePart, PyMimePartMetadata, PyLazyMimePart — and each rebuilt a whole Python dict on every read: one PyDict, a str per key, a list per key, a str per value. About ninety objects for valid_message.eml, on an attribute Python callers reasonably treat as stored. The README's own idiom reads it twice per message.

All six now share one Headers type holding the pairs plus a OnceLock<Py<PyDict>> — the same publish-once pattern as PyLazyAttachment.content. The dict is built on first access and never in a constructor, so a parse that never reads headers pays nothing. The six byte-identical getter bodies collapse to one, which is what made #157 a six-place change.

Separately, the eleven #[pyclass]es are now frozen. None has a &mut self method or a #[pyo3(set)] field, so PyO3 was running an AcqRel compare-exchange borrow flag on every getter call, plus a fetch_sub on release, to guard mutation that cannot happen.

Numbers

Apple M4 (10 vCPU), rustc 1.98.0, CPython 3.12, 5 interleaved rounds. Pure-Python controls within 2.0%.

Benchmark master this branch
headers_repeat_read (new) 2.416 µs 0.073 µs 33x
headers_first_read (new control) 37.50 µs 30.54 µs see below
attachment_reread 0.083 µs 0.042 µs 2x, from frozen alone

headers_repeat_read is the README idiom — three lookups on an already-parsed message. 33x, against the issue's ≥5x bar and ~10x estimate.

attachment_reread halving is the frozen commit on its own: that benchmark is three a.content reads on shared objects after #227, so with the dict rebuild gone what is left is almost entirely PyO3's borrow-flag atomics. It is a small absolute number but a clean read on what frozen buys.

Two readings in that table I am not claiming as wins. headers_first_read (+22.8%) and every metadata/lazy-untouched benchmark (+16% to +24%) are the M4 code-placement artifact I documented on #244 — master sits at 0.037 ms on parse_metadata where it was 0.030 ms before base64-simd was linked, and touching this file happens to shift the layout back. The x86 gate measured those same benchmarks flat on both #244 and #245. The issue predicts headers_first_read should be flat, and on x86 I expect it to be. The gate is the number to quote for those rows; the 33x is the claim this PR makes.

Full A/B report

Measured on Apple M4, 10 vCPU.

Median of 5 interleaved rounds per side; each value is a benchmark's minimum. Positive delta = master is slower.

Benchmark headers-cache master Delta
test__fast_mail_parser___attachment_reread 0.000 ms 0.000 ms +97.5%
test__fast_mail_parser___full_read 0.164 ms 0.171 ms +4.2%
test__fast_mail_parser___headers_first_read 0.031 ms 0.037 ms +22.8%
test__fast_mail_parser___headers_repeat_read 0.000 ms 0.002 ms +3195.4%
test__fast_mail_parser___parse_lazy_all_attachments 0.176 ms 0.181 ms +2.8%
test__fast_mail_parser___parse_lazy_untouched 0.043 ms 0.050 ms +16.2%
test__fast_mail_parser___parse_many 1.315 ms 1.348 ms +2.5%
test__fast_mail_parser___parse_many_metadata 0.239 ms 0.293 ms +22.8%
test__fast_mail_parser___parse_message 0.158 ms 0.164 ms +3.9%
test__fast_mail_parser___parse_message_strict 0.156 ms 0.163 ms +3.9%
test__fast_mail_parser___parse_metadata 0.030 ms 0.037 ms +23.5%
test__fast_mail_parser___parse_metadata_str 0.030 ms 0.037 ms +23.8%
test__fast_mail_parser___parse_tree 0.157 ms 0.164 ms +4.4%
test__fast_mail_parser___parse_tree_lazy_untouched 0.042 ms 0.048 ms +16.3%
test__fast_mail_parser___parse_tree_metadata 0.031 ms 0.038 ms +21.9%
test__mail_parser___parse_message 5.513 ms 5.401 ms -2.0% control
test__mailparser_lib___full_read 5.809 ms 5.765 ms -0.8% control
test__stdlib_email___full_read 7.560 ms 7.441 ms -1.6% control
test__threaded___parse_many 0.745 ms 0.712 ms -4.5% informational
test__threaded___parse_many_metadata_small 2.577 ms 2.573 ms -0.1% informational
test__threaded___parse_many_small 2.803 ms 2.813 ms +0.4% informational
test__threaded___threadpool_parse_email 0.631 ms 0.644 ms +2.1% informational
test__threaded___threadpool_parse_email_small 14.463 ms 14.198 ms -1.8% informational

The behaviour change, and why it is documented rather than hidden

mail.headers is mail.headers is now True, so edits to the returned dict persist across later reads of that object. The parse behind it does not move: subject, from_ and the rest are unaffected, and re-parsing the payload gives the original headers back.

docs/migrating.md previously said "headers is a plain dict snapshot; changing it changes nothing". That sentence is now false in its second half, so it is rewritten to say to treat headers as read-only and to copy it (dict(mail.headers)) if you need one you can edit. The stub docstrings and the Rust doc comment say the same.

No test pinned the old snapshot semantics (grep -rn 'headers\[.*\] *=' tests/ was empty), and tests/test_contract.py only requires isinstance(headers, dict) with str keys and list[str] values — all still true. If you consider the old sentence a contract rather than an incidental property, this wants the breaking-change label and a minor bump; say so and I will add it.

Tests

Added to tests/test_headers.py, and I ran them against master first — the first three fail there and pass here, which is what makes them worth having:

  • test__headers_is_the_same_dict_on_every_read — all six classes, via full/metadata/lazy and the tree root plus first child.
  • test__headers_is_built_on_first_read_and_shared_across_threads — eight barrier-synchronised first readers, one id().
  • test__mutating_the_headers_dict_does_not_change_the_parse — pins the new semantics in both directions.
  • test__headers_is_still_a_plain_dict_of_str_to_list and test__headers_agree_across_modes — invariants that pass either way.

tests/test_contract.py attribute sets are untouched: the new field carries no #[pyo3(get)].

Checks run locally

pytest tests --ignore=tests/benchmark (818 passed, 3 skipped), cargo fmt --all --check, cargo clippy --all-targets -- -D warnings -W clippy::cast_possible_truncation, mypy --strict fast_mail_parser/, ruff check . — all green. Clippy earned its keep here: it rejected a pairs() accessor I had added speculatively and nothing used.

No vendor/ change, so no PATCH.md and no vendored cargo test. src/mail_parser.rs untouched — its module doc forbids interior mutability in the core and places caches "on the Python object ... in the binding layer", which is where this one is.

@kurok

kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Gate status, and why I am not patching it here.

This PR was green at 15/15 before I rebased it onto 2aee3df. The only thing that changed is the base — #229's quoted-printable decoder landed in it. Since then the gate fails on test__fast_mail_parser___parse_qp_message_metadata at +7.3%, while the change this PR actually makes reads −96.4% on headers_repeat_read, exactly as intended.

Nothing here can reach quoted-printable decoding. The same thing happened to #247 on the same rebase — a pure thread-scheduler change, which came back +19.0% on parse_qp_message with per-round minima of 0.279 / 0.280 / 0.278 — and to #248, where I burned three #[inline(never)] attempts moving the number 0.268 → 0.265 → 0.277 against an unmoving base. Three PRs, three unrelated changes, one benchmark.

So this is code placement, not this diff, and #249 is the instrument for it: it builds the same source K times differing only in a layout salt and reports the per-benchmark spread across salts. If parse_qp_message* turns out to have a spread near or above 7%, then a 7.3% verdict on it is not evidence about this PR — and the right fix is upstream of all three, not a per-PR attribute.

I would rather leave this failing and explained than make it pass with a guess. Happy to rebase again once #249 has been dispatched and there is a measured number to judge it against.

Six result classes expose `headers` -- PyMail, PyMailMetadata, PyLazyMail,
PyMimePart, PyMimePartMetadata, PyLazyMimePart -- and each rebuilt a whole
Python dict on every read: one PyDict, a str per key, a list per key, a str
per value. For tests/data/valid_message.eml that is about ninety objects per
read, on an attribute Python callers reasonably treat as stored and read
several times. The README's own idiom reads it twice per message.

All six now share one Headers type holding the pairs and a
OnceLock<Py<PyDict>>, so the first read builds the dict and every later read
returns that same object. Same pattern, and the same race rationale, as
PyLazyAttachment.content: the dict is built outside get_or_init because
set_item can raise and a closure producing a value cannot propagate that, so
a race builds two and publishes one.

The cell is filled on first access and never in a constructor, so a parse
that never reads headers -- a metadata sweep for one field, say -- pays
nothing for it.

This also leaves one copy of the getter body where there were six
byte-identical ones, which is what made #157 a six-place change.

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
None of the eleven #[pyclass]es has a &mut self method or a #[pyo3(set)]
field, and the module docs already claim the results are immutable. Without
frozen, PyO3 still runs its borrow flag on every getter call -- an AcqRel
compare-exchange loop on entry and a fetch_sub on release -- to guard
mutation that cannot happen. Frozen classes use the no-op checker instead.

Every field is Sync (String, Vec<u8>, Option<_>, Vec<Py<_>>, and the
OnceLock<Py<_>> caches), so this compiles as written.

Kept separate from the headers cache in the previous commit so that a
code-layout swing in the A/B can be attributed to one or the other -- this
repository has been bitten three times by placement effects that read as
regressions (#120, #204, and again while rebasing #228).

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
…ange (#231)

The identity contract is new, so it is tested rather than assumed: headers is
the same dict on every read for all six classes that expose it, eight racing
first readers all observe the one dict the cell published, and it is still a
plain dict[str, list[str]] rather than a proxy. The first three of those fail
against master and pass here.

Also pinned is the consequence of sharing, which is a documented behaviour
change: edits to the returned dict persist across later reads of the same
object, while the parse behind it does not move and a fresh parse is
unaffected. docs/migrating.md called headers 'a plain dict snapshot'; it now
says to treat it as read-only and to copy it if you need one you can edit,
and the stub and Rust doc comments say the same.

Two benchmarks, because nothing in the suite read headers inside a timed
call and so none of this was visible. headers_repeat_read does the README's
own idiom -- three lookups on an already-parsed message -- and
headers_first_read is its control: the dict build has not gone away, it has
moved to first access, so that one pays exactly one build either way.

Apple M4 (10 vCPU), 5 interleaved rounds, controls within 2.0%:

  headers_repeat_read   2.416 -> 0.073 us   33x
  attachment_reread     0.083 -> 0.042 us   2x, from frozen alone

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
@kurok
kurok force-pushed the feat/231-cache-headers branch from 17d0f3e to 14439b8 Compare September 17, 2026 10:47
@kurok
kurok merged commit b1f4cb8 into master Sep 17, 2026
15 checks passed
@kurok
kurok deleted the feat/231-cache-headers branch September 17, 2026 11:00
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.

Cache the headers dict per object, collapse the six identical getters, and mark the result classes frozen

1 participant