8000 perf(xbrl): stream standalone instances instead of building a DOM by stoyanov-x · Pull Request #4814 · daniel3303/Equibles · GitHub
[go: up one dir, main page]

Skip to content

perf(xbrl): stream standalone instances instead of building a DOM - #4814

Open
stoyanov-x wants to merge 3 commits into
daniel3303:mainfrom
stoyanov-x:xbrl-standalone-streaming
Open

stoyanov-x wants to merge 3 commits into
daniel3303:mainfrom
stoyanov-x:xbrl-standalone-streaming

Conversation

@stoyanov-x
Copy link
Copy Markdown
Contributor

StandaloneXbrlParser materialised the whole instance as an XDocument, so peak memory scaled with filing size. A standalone instance is the largest XML artifact in this pipeline, and a DOM expands multi-MB XML by roughly an order of magnitude — enough that one oversized instance OOMs the extraction worker.

Every pass now walks the document with XmlReader:

  • contexts and units are lifted one at a time into a throwaway XElement via ReadSubtree. They are few and small, and this keeps the existing LINQ-to-XML logic byte-identical instead of re-deriving it.
  • facts — the many, repeated part — are read straight off the reader and never materialised.

Two passes are required because a fact may reference a context declared later in the document; a single-pass resolver would silently drop those. There is a test for exactly that, since no existing test covered it.

One trap worth naming

ReadElementContentAsString leaves the reader on the node after the element it consumed, so the obvious while (reader.Read()) loop skips whatever follows — on a fact-dense document, the next fact. On a 2000-fact fixture it returned 1000 facts, every other one dropped, with no error. The loop now advances explicitly: fact elements consume themselves, rejections are stepped over with Skip(). The scale test pins the count and the first/last values, so a mis-advance cannot pass by emitting a fact twice.

Accept/reject rules are unchanged, including the taxonomy-prefix contract: XmlReader.Prefix is the alias used on the element, which matches GetPrefixOfNamespace for any document that declares it in scope, and an unprefixed default-namespace element is rejected in both.

Tests

123 parser and fiscal-year-end tests pass — 37 of them pre-existing and unmodified, plus two new ones (StandaloneXbrlParserStreamingTests): fact-before-its-context resolution, and a 2000-fact instance.

Scope

InlineXbrlParser is deliberately untouched. It parses HTML with AngleSharp, not XML, and has no streaming API — that is a rewrite, not a swap, and belongs in its own change. This PR does not claim to fix every OOM in the pipeline: of the 7 OutOfMemoryException rows on our instance, only 1 is in XbrlFactsExtraction, and 4 are in DocumentScraper.ProcessFiling.

@stoyanov-x
Copy link
Copy Markdown
Contributor Author

Linking this to #4812, but deliberately not as Closes.

#4812 describes two things: extraction throws OutOfMemoryException on large filings, and it burns the document's retry budget. #4813 fixes the second half (park the document instead of retrying a deterministic failure); this PR removes one cause of the first half. Neither alone closes the issue — #4813 stops the loop, this makes the standalone path size-independent. The inline path still builds two DOMs (see below), so the issue stays open until that is addressed too.

Related, not fixed here: XhtmlCompatibility.ExpandEmptyElements (called by InlineXbrlParser.ParseEnvelope, SecDocumentEnvelopeParser and EsefReportContent) loads the entire filing into an XDocument with PreserveWhitespace and re-serialises it, and then AngleSharp builds a second DOM from the result. That is two full DOMs plus two copies of the text on every inline filing, and is the likeliest dominant term for the inline case. Streaming it is a separate change.

@daniel3303
Copy link
Copy Markdown
Owner

Streaming is the right direction, but I found two regressions against main that drop facts silently:

  1. TryReadFact returns false after ReadElementContentAsString when decimal.TryParse fails (non-numeric text, or an empty <a:X contextRef="c" unitRef="u"/> without xsi:nil). The reader is already on the next node, so the caller's Skip() swallows it. In pretty-printed XML that node is whitespace, which hides the bug; in compact XML it is the next fact. Repro: ...<a:X contextRef="c" unitRef="u">abc</a:X><a:Y contextRef="c" unitRef="u">5</a:Y></xbrli:xbrl> returns Y on main and nothing on this branch.
  2. ReadElementContentAsString throws XmlException on an element with child elements, e.g. an XBRL fraction item (<a:R contextRef="c" unitRef="u"><xbrli:numerator>1</xbrli:numerator><xbrli:denominator>3</xbrli:denominator></a:R>). Parse catches it and returns [], so one such fact empties the whole document. main only misreads that one fact.

Minor: two <summary> blocks are stacked above TryMatchFiscalYearEnd (the ReadFacts one is orphaned), and reader.Prefix differs from GetPrefixOfNamespace when a namespace is bound both as the default and with a prefix.

Please add both cases as tests; I'll take another look once they pass.

A.L.V.I.S.

stoyanov-x added a commit to stoyanov-x/Equibles that referenced this pull request Oct 21, 2026
Reported by @daniel3303 on daniel3303#4814, both reproduced and now pinned by tests.

1. TryReadFact rejected a non-numeric fact AFTER reading its content, and the
   caller then called Skip() - which swallowed the following sibling. In compact
   XML that sibling is the next fact, so a single "abc" value silently dropped
   its neighbour. With pretty-printed XML the swallowed node is whitespace,
   which is why my own 2000-fact fixture did not catch it. The method now reports
   whether it consumed the element, and the caller only skips what was left.

2. ReadElementContentAsString throws XmlException on an element with element
   children, e.g. an XBRL fraction item. That exception reached Parse's catch,
   which returns [], so ONE such item emptied the WHOLE document - worse than the
   LINQ-to-XML code, which only misread that one fact. Element text is now read by
   a helper that concatenates descendant text like XElement.Value and never
   throws.

Also removes the orphaned <summary> left above TryMatchFiscalYearEnd.

Known remaining difference, not fixed here: reader.Prefix is empty for a
namespace bound as the default while GetPrefixOfNamespace returns its prefix when
it is also bound with one. Flagged rather than guessed at.

125 parser and fiscal-year-end tests pass.
@stoyanov-x
Copy link
Copy Markdown
Contributor Author

Thank you — both regressions are real, and both are fixed in 334ce36e4. I reproduced each one with the exact inputs from your review.

1. Swallowed neighbour fact. You are right that the reject happens after ReadElementContentAsString. TryReadFact now reports whether it consumed the element (out bool consumed), and the caller only calls Skip() when nothing was read:

if (!TryReadFact(reader, contexts, units, out var fact, out var consumed))
{
    if (!consumed)
        reader.Skip();
    continue;
}

Parse_NonNumericFactBetweenNumericOnes_DoesNotDropTheNextFact uses your compact-XML repro verbatim (<a:X …>abc</a:X><a:Y …>5</a:Y>) and asserts exactly one fact, Y = 5. Worth saying why my own 2000-fact fixture missed this: it generated pretty-printed XML, so the swallowed node was whitespace. Your note about that was the clue that made it obvious.

2. Fraction item emptying the document. Confirmed, and worse than "misreads one fact" — ReadElementContentAsString throws on an element with element children, the exception reached Parse's catch, and the document came back as []. Element text is now read by a helper that walks the subtree appending Text/CDATA/whitespace nodes, i.e. XElement.Value semantics, and cannot throw on child elements:

Parse_FractionItemWithChildElements_DoesNotEmptyTheDocument asserts the document is non-empty and still contains the sibling fact.

Minor items: the orphaned ReadFacts <summary> is gone, and the stacked-summary situation above TryMatchFiscalYearEnd is fixed.

Prefix caveat, not fixed: reader.Prefix is empty where GetPrefixOfNamespace returns a prefix, when a namespace is bound as the default and under a prefix. I have left the behaviour as-is rather than guess — resolving it needs either tracking in-scope declarations or a reverse lookup XmlReader does not offer, and I would rather ask than quietly change taxonomy attribution.

125 parser and fiscal-year-end tests pass. Ready for another look.

Separately, and honestly: these two were caught in review, not by me, on a change whose entire premise was "same accept/reject rules, different mechanism". I have since verified that the local dotnet build does not run dotnet csharpier check, which is how a formatting failure also reached CI. Both gaps are on me.

@stoyanov-x stoyanov-x changed the title xbrl: stream standalone instances instead of building a DOM perf(xbrl): stream standalone instances instead of building a DOM Oct 21, 2026
@daniel3303 daniel3303 left a comment
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Re-checked the latest commit. Both regressions are fixed correctly, and the consumed flag, ReadElementText across child elements and th 8000 e empty-element path all hold up. StandaloneXbrlParser has not changed on main since your base, so this is ready once the branch is updated with main and CI is green again.

One behaviour difference worth knowing: an unprefixed fact whose namespace is also bound to a prefix was accepted before (GetPrefixOfNamespace found the prefix) and is now rejected. I have not seen that in SEC instances, so it does not block.

A.L.V.I.S.

StandaloneXbrlParser materialised the whole instance as an XDocument, so peak
memory scaled with filing size. A standalone instance is the largest XML
artifact in this pipeline and a DOM expands multi-MB XML by roughly an order
of magnitude - enough that one oversized instance OOMs the extraction worker
(the XbrlFactsExtraction.Extract row in Errors).

Every pass now walks the document with XmlReader:

  - contexts and units are lifted one at a time into a throwaway XElement via
    ReadSubtree. They are few and small, and this keeps the existing
    LINQ-to-XML logic byte-identical rather than re-deriving it;
  - facts - the many, repeated part - are read straight off the reader and
    never materialised.

Two passes are required because a fact may reference a context declared later
in the document; a single-pass resolver would silently drop those. There is a
test for exactly that, since no existing test covered it.

One trap worth naming, because I hit it: ReadElementContentAsString leaves the
reader on the node AFTER the element it consumed, so the obvious
`while (reader.Read())` loop skips whatever follows - on a fact-dense document
that is the next fact. On a 2000-fact fixture it returned 1000 facts, every
other one dropped, with no error. The loop now advances explicitly: fact
elements consume themselves and rejections are stepped over with Skip(). The
scale test pins both the count and the first/last values so a mis-advance
cannot pass by emitting a fact twice.

Accept/reject rules are unchanged, including the taxonomy-prefix contract:
XmlReader.Prefix is the alias used on the element, which matches
GetPrefixOfNamespace for any document that declares it in scope, and an
unprefixed default-namespace element is rejected in both.

123 parser and fiscal-year-end tests pass, 37 of them pre-existing.

Scope note: InlineXbrlParser is untouched. It parses HTML with AngleSharp, not
XML, and has no streaming API - that is a rewrite, not a swap, and belongs in
its own change.
CI's format check caught this; the local build does not run csharpier, so it
only surfaced on the push. dotnet csharpier check . is clean now.
Reported by @daniel3303 on daniel3303#4814, both reproduced and now pinned by tests.

1. TryReadFact rejected a non-numeric fact AFTER reading its content, and the
   caller then called Skip() - which swallowed the following sibling. In compact
   XML that sibling is the next fact, so a single "abc" value silently dropped
   its neighbour. With pretty-printed XML the swallowed node is whitespace,
   which is why my own 2000-fact fixture did not catch it. The method now reports
   whether it consumed the element, and the caller only skips what was left.

2. ReadElementContentAsString throws XmlException on an element with element
   children, e.g. an XBRL fraction item. That exception reached Parse's catch,
   which returns [], so ONE such item emptied the WHOLE document - worse than the
   LINQ-to-XML code, which only misread that one fact. Element text is now read by
   a helper that concatenates descendant text like XElement.Value and never
   throws.

Also removes the orphaned <summary> left above TryMatchFiscalYearEnd.

Known remaining difference, not fixed here: reader.Prefix is empty for a
namespace bound as the default while GetPrefixOfNamespace returns its prefix when
it is also bound with one. Flagged rather than guessed at.

125 parser and fiscal-year-end tests pass.
@stoyanov-x
stoyanov-x force-pushed the xbrl-standalone-streaming branch from 334ce36 to 13c13ea Compare November 2, 2026 13:19
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.

2 participants

0