fix: Cut OpenLab CDS peak memory 4x and handle optional ACAML metadata - #1254
Merged
nathan-stender merged 1 commit intoAug 25, 2026
Merged
Conversation
Large .rslt bundles (745MB, 10 injections) could not be converted: the read path held the whole file in memory twice, and four ACAML elements that are absent from some exports raised on access. Memory: - Add NamedFileContents.get_seekable_bytes_stream(), which returns the underlying handle when it is already a seekable binary stream instead of copying the file into a BytesIO. to_allotrope and discover_vendor both seek(0) before handing the handle to a parser, so sharing it is safe. - Spool each .dx member to a temp file before opening it as a nested zip. Opening a ZipFile over a non-seekable ZipExtFile made zipfile emulate every backward seek by re-reading in 16MB chunks, and the .CH members sit at the very end of a 74MB member. Peak RSS for the decode stage drops from ~1.00GB to ~235MB, and decoding is also faster now that the seek emulation is gone. Optional metadata, each of which previously raised: - Resources/SeparationMedium absent -> omit the column fields. - No .sqx sequence file -> read the analysis method from the ACAML method documents, when they agree on a single name. - Result/InjectionCompound absent -> emit peaks without compound metadata. - Single-token instrument name -> omit brand name rather than IndexError. Also guard the unconditional Area/Height/"Peak Metadata" reads in create_peak, whose sibling expressions already treated those keys as optional. Add a 33KB test fixture covering all four conditions, cut down from the existing Luxo HPLC fixture: 2 injections, and only the three .dx members the parser actually reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stephenworlow
approved these changes
Aug 24, 2026
nathan-stender
deleted the
fix/openlab-cds-memory-and-optional-metadata
branch
August 25, 2026 15:45
This was referenced Aug 25, 2026
nathan-stender
added a commit
that referenced
this pull request
Aug 25, 2026
## Problem The OpenLab CDS help docs tell users they may compress their `.rslt` result set before dropping it in the File Watched directory: > (Optional) Users can additionally compress their RSLT file to a zipped file within their File Watched directory if they choose. The Agilent OpenLabs CDS connector is able to process this format along with the standard RSLT file export. That was never true in allotropy. `SUPPORTED_EXTENSIONS = "rslt"` meant a `.zip` upload was rejected before the parser saw any bytes: ``` EXPLICIT .zip FAIL: AllotropeConversionError Unsupported file extension 'zip' for parser 'Agilent OpenLab CDS', expected one of '['rslt']'. DISCOVER .zip FAIL: AllotropeVendorNotFoundError No vendor could be identified for file with extension '.zip'. ``` The `.rslt` files we already accept *are* zip archives, so the contents were never the problem — only the extension gate and, for one shape, an extra layer of nesting. ## Changes - **Accept the `zip` extension** in addition to `rslt`. - **Replace the unconditional `sniff()` with a content check.** `return True` was only safe while `.rslt` was exclusive to this parser. With `zip` added, the old sniff would have claimed every `.zip` and stolen files from Cytiva Unicorn (`"zip"`) and AppBio Absolute Q (`"csv,zip"`). It now looks for an ACAML member. - **Unwrap extra layers of compression** (`open_result_set`). Compressing the `.rslt` *folder* yields an archive whose members are the result set; compressing the `.rslt` *file* yields an archive whose only member is another archive. Both now work. - **Clearer errors.** A non-result-set archive raised bare `StopIteration`, and an uncompressed file raised `BadZipFile`. Both now raise `AllotropeConversionError` with a message, since malformed archives are newly reachable input. ## Verification Tested against archives produced by the real macOS Finder "Compress" path (`ditto -c -k --sequesterRsrc --keepParent`, which adds the `__MACOSX` resource forks that hand-built zips lack): ``` finder_folder.zip parse=IDENTICAL discover=AGILENT_OPENLAB_CDS finder_folder_renamed.rslt parse=IDENTICAL discover=AGILENT_OPENLAB_CDS finder_file.zip parse=IDENTICAL discover=AGILENT_OPENLAB_CDS finder_file_renamed.rslt parse=IDENTICAL discover=AGILENT_OPENLAB_CDS ``` IDENTICAL = byte-equal ASM against the existing fixture (~192KB of output, data cubes included), ignoring `file name` / `UNC path`, which legitimately differ. - Full suite: **1496 passed**, `hatch run lint` clean. - `tests/discover_vendor_test.py` passes, confirming no cross-vendor sniff regressions. ## Notes for reviewers - The new test **synthesizes** each compression shape from the existing `.rslt` fixture rather than checking in new files, avoiding ~3MB of duplicate test data. - Mutation-tested the new test (`MAX_ARCHIVE_NESTING` 3 → 1): exactly the two nested cases fail, so the assertions aren't vacuous. - Nesting is bounded at 3 archive levels. Beyond that a user would have to compress three or more times, which the docs don't describe. - **Still open, outside this repo:** if the connector zips the watched folder itself, a pre-zipped upload may not reach allotropy to benefit from this. The `Unsupported file extension 'zip'` error above is the signal that it did. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Update: rebased on main (2026-08-25) Merged latest `main`, which now contains #1254 (OpenLab CDS memory + optional ACAML metadata) and #1258. Resolving the overlap in `agilent_openlab_cds_decoder.py` changed two things in this PR: - **`.sqx` stays optional.** This PR originally routed the sequence-file lookup through a new `_get_first_matching_filename` helper that raised when `.sqx` was absent. #1254 made `.sqx` optional (falling back to the ACAML method documents), and the `Sirius` fixture it added has no `.sqx`, so main's behaviour wins. The helper became dead code and was removed — the "clearer errors" bullet above now applies only to the ACAML and non-archive cases, both still covered by tests. - **`sniff` no longer copies the whole file into memory.** It now calls `get_seekable_bytes_stream()` instead of `get_bytes_stream()`. Result sets reach ~1GB, and `get_bytes_stream()` buffers the entire upload — using it in `sniff` would have silently undone #1254's memory fix on every discovery pass. Also fixed the `Quality Checks` failure that predated the merge: `from_file` returns `Mapping[str, Any]`, not `dict[str, Any]`, so the `expected` fixture's annotation failed mypy. Re-verified after the merge: **1498 passed** (up from 1496 — main added the two `Sirius` fixture cases), `hatch run lint` clean, `tests/discover_vendor_test.py` green. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
nathan-stender
added a commit
that referenced
this pull request
Aug 25, 2026
#1257) ## Motivation Sample files attached to parser issues are usually **real customer exports**, but nothing in `CLAUDE.md`, `CONTRIBUTING.md`, or the parser-generator skill said so, or said what to do about it. This adds a `## Test Data` section covering the two requirements and the traps that make each easy to get wrong. Prompted by #1254, where a 745 MB customer bundle had to be reduced to a 33 KB fixture and the rules for doing so were entirely ad-hoc. ## What's documented **1. Anonymize every customer-unique identifier** — instrument/site/project names, operators and clients, serials and part numbers, barcodes, sample and method names, absolute paths (which embed usernames), GUIDs. Two non-obvious points: - Some identifiers are **load-bearing**: the parser matches on them, so renaming one side of a reference silently breaks the fixture. Concrete cases: ACAML `TraceID` GUIDs must equal the `.dx` member filenames, and signal names like `DAD1A,Sig=280,4 Ref=off` are parsed for wavelength and bandwidth. - **Shape affects control flow**, not just readability — `Kaisla` and `Luxo HPLC` take different paths, because `brand_name` reads the second whitespace token. **2. Trim to a representative sample** — KBs, not MBs. Drop container members the parser never opens (an OpenLab CDS `.dx` is ~75 MB but the 71 MB `.UV` member is never read), cut repeated records, downsample long series. Two gotchas: - **Keep list-shaped XML at 2+ elements** — `xmltodict` maps a single repeated element to a dict rather than a list, so trimming a section to one entry breaks any code that indexes it. - **Prefer deriving from an existing anonymized fixture** when the new case is a metadata variation rather than a new binary layout. Reintroduces zero customer data and is usually smaller. **3. Verify and generate** — stash the fix and confirm the fixture actually fails before trusting it as regression coverage. Also writes down two rules that previously lived only in `.claude/skills/parser-generator/skill.md` or nowhere at all: generate expected JSON with `--overwrite` (the framework mocks UUIDs, so hand-generated JSON can never match), and never edit existing expected JSON to make a change pass. ## Scope Docs only — no code, no test, no behavior change. `CONTRIBUTING.md` is left alone; it targets external contributors and would want a different framing, so that can be a follow-up if wanted. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
A 745 MB Agilent OpenLab CDS
.rsltbundle (10 injections) could not be converted at all. Two independent problems:Memory
NamedFileContents.get_seekable_bytes_stream(). The existingget_bytes_stream()doesself.contents.read()and wraps the result in aBytesIO, holding the file twice. The new method returns the underlying handle when it's already a seekable binary stream (as it is forallotrope_from_file), falling back to the old behaviour otherwise. Safe becauseto_allotrope.pyanddiscover_vendor()bothseek(0)before handing the handle to a parser, and the new method seeks to 0 itself. Only the OpenLab CDS parser opts in; the ~30 other callers ofget_bytes_stream()are untouched.decode_datanow spools each.dxmember to aNamedTemporaryFilein 1 MB chunks before opening it as a nested zip. Previously it opened aZipFileover aZipExtFile, which isn't seekable, sozipfileemulated every backward seek by re-reading and discarding in 16 MB chunks — and the.CHmembers sit at byte ~74.1 M of a 74.5 MB member.Measured with an RSS sampler on the 745 MB bundle: peak RSS for the decode stage ~1.00 GB → ~235 MB; full end-to-end conversion peaks at ~218 MB. Decoding is also faster (1.7 s → 1.3 s) now that the seek emulation is gone.
Optional ACAML metadata
Each of these previously raised on the affected file:
decode_acaml_dataResources/SeparationMediumabsent →KeyError.get(), plus newget_column_info()in the structure layer, which resolves to{}so the column fields are omitteddecode_data.sqxin the bundle →StopIteration.sqxis now optional; the analysis method falls back to the ACAMLContent/Methodnames via newget_acaml_analysis_method(), used only when they agree on a single nameextract_rx_fileResult/InjectionCompoundabsent →KeyErrorresult_data.get("InjectionCompound"); peaks are emitted without compound metadatacreate_metadata.split()[1]IndexErrorget_brand_name(), returns the second token only when presentAlso guarded the unconditional
peak["Area"]["@unit"],peak["Height"]["@unit"]andpeak["Peak Metadata"]reads increate_peak, whose sibling expressions already treated those keys as optional.The bundle now converts to 30 measurements (10 injections × 2 chromatograms + 1 pressure trace) in 2.0 s.
Test data
New fixture
Sirius-2023-09-01 07-52-44-04-00.rslt, 33 KB, containing no customer data. Cut down from the existing anonymisedLuxo HPLCfixture: 2 injections instead of 10, and each.dxkeeps only the three members the parser reads (2.CH+ the pressure.IT), dropping ~1.1 MB of unread instrument traces per injection. Then edited to reproduce all four conditions above.Confirmed to be real regression coverage: with the source changes stashed, the new fixture fails (
KeyError: 'SeparationMedium') while the existing fixture still passes.The pre-existing
Luxo HPLCexpected JSON is byte-identical — no changes to existing test data.Out of scope
.UVmember is 95.5% of each.dxand is not read, so the output has no 3D spectra. ASM does definethree-dimensional ultraviolet spectrum data cube(AFR_0002551) in the LC schema and generated model, but it isn't exposed by the schema mapper and no parser emits it. Deliberately deferred.Calibrationsample role.constants.SAMPLE_ROLE_TYPEmaps onlyBlankandSample, so calibration standards get no sample role type. Adding it would change output for existing files containing calibration samples, so it's left for a separate change.decode_data_cubesconverts numpy float64 arrays into Python float lists (~4× blowup). Peak is now dominated by retained data rather than file I/O. Keeping the cubes as numpy arrays until serialization is the next lever.Testing
hatch run lint— clean (ruff, black, mypy over 788 files)hatch run test_all.py3.10:pytest tests/— 1488 passed🤖 Generated with Claude Code