Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
121 commits
Select commit Hold shift + click to select a range
6fcd15c
chore: promote public API for v6.0.0
GabrielMarquezMatte Oct 7, 2026
d3a3c3a
Merge pull request #137 from GabrielMarquezMatte/chore/promote-public…
GabrielMarquezMatte Oct 7, 2026
d86e4af
feat(python): enhance Arrow stream handling with GIL management and t…
GabrielMarquezMatte Oct 7, 2026
5947e6e
Merge branch 'develop' of https://github.com/GabrielMarquezMatte/Exce…
GabrielMarquezMatte Oct 7, 2026
57dc08c
feat(core): open a workbook from any byte source through a seekable view
GabrielMarquezMatte Oct 7, 2026
fcb2605
feat(core): cache a byte source in blocks fetched once
GabrielMarquezMatte Oct 7, 2026
5574daa
feat(native): add source and buffering options and keep accepting the…
GabrielMarquezMatte Oct 7, 2026
d46ec61
fix(native): make the 6.0 options size a constant and document it
GabrielMarquezMatte Oct 7, 2026
6bc46a0
feat(native): open a workbook over a source the caller serves
GabrielMarquezMatte Oct 7, 2026
0e20232
feat(native): open a workbook over a stream that cannot seek
GabrielMarquezMatte Oct 7, 2026
4714b45
test(native): open a workbook through xl_open_source from C
GabrielMarquezMatte Oct 7, 2026
c933af8
feat(rust): open a workbook from a Source or any Read
GabrielMarquezMatte Oct 7, 2026
962e872
test(rust): pin source release counts and the new ABI layouts
GabrielMarquezMatte Oct 7, 2026
4db2192
feat(cpp): open a workbook from an xl::Source or an xl::InputStream
GabrielMarquezMatte Oct 7, 2026
2b1b3ad
feat(python): open a workbook from a Source or a readable stream
GabrielMarquezMatte Oct 7, 2026
995dec9
fix(python): register a source only after every fallible step, and gu…
GabrielMarquezMatte Oct 7, 2026
fe9a3b6
docs: read workbooks from your own sources and streams
GabrielMarquezMatte Oct 7, 2026
79f6201
fix: final-review fixes for callback sources
GabrielMarquezMatte Oct 7, 2026
e269dc6
feat: enhance CSV stream handling to support schema inference and rep…
GabrielMarquezMatte Oct 7, 2026
d5da1e1
Bump the analyzers group with 1 update
dependabot[bot] Oct 7, 2026
d185665
Bump the test group with 2 updates
dependabot[bot] Oct 7, 2026
77f97b6
Removing unnecessary comments
GabrielMarquezMatte Oct 7, 2026
4f8535d
Merge pull request #138 from GabrielMarquezMatte/dependabot/nuget/dev…
GabrielMarquezMatte Oct 8, 2026
1975041
Merge pull request #139 from GabrielMarquezMatte/dependabot/nuget/tes…
GabrielMarquezMatte Oct 8, 2026
1514726
feat: add Huffman decode-table builder for the managed inflate decoder
GabrielMarquezMatte Oct 8, 2026
d7f1559
feat: add a managed streaming inflate decoder
GabrielMarquezMatte Oct 8, 2026
7f02562
perf: inflate ZIP entries with the managed decoder
GabrielMarquezMatte Oct 8, 2026
58bb3ca
test: fuzz the managed inflate decoder against DeflateStream
GabrielMarquezMatte Oct 8, 2026
0929fc6
perf: benchmark the managed inflate decoder against DeflateStream
GabrielMarquezMatte Oct 8, 2026
408b76e
docs: record the managed inflate measurements
GabrielMarquezMatte Oct 8, 2026
2ad7123
fix: reject a truncated deflate stream before handing out padding-dec…
GabrielMarquezMatte Oct 8, 2026
0579fa4
fix(fuzz): let inflate divergences and canary trips escape the oracle
GabrielMarquezMatte Oct 8, 2026
5997074
fix: update binary file for ExcelReader native integration
GabrielMarquezMatte Oct 8, 2026
ab3aa1e
perf: tighten the inflate fast loop
GabrielMarquezMatte Oct 8, 2026
1d3afab
perf(native): regenerate the static PGO profile after the inflate fas…
GabrielMarquezMatte Oct 8, 2026
640a6e2
Merge branch 'develop' of https://github.com/GabrielMarquezMatte/Exce…
GabrielMarquezMatte Oct 8, 2026
782ad91
perf(native): build typed columns in native memory and hand them over…
GabrielMarquezMatte Oct 8, 2026
fc6e38a
Merge branch 'master' into develop
GabrielMarquezMatte Oct 8, 2026
d2ec3cf
perf(native): regenerate the static PGO profile after the native colu…
GabrielMarquezMatte Oct 8, 2026
df3634c
ci: run InflateBenchmark with the read group
GabrielMarquezMatte Oct 8, 2026
7a08ec2
fix(generator): set required members in the generated factory
GabrielMarquezMatte Oct 8, 2026
65945b4
feat(native): add support for auto-detecting CSV format and implement…
Oct 9, 2026
415d97f
perf(docs): update benchmark results for improved performance metrics…
Oct 9, 2026
7e04748
perf(docs): update benchmark results for XLSX, XLSM, XLSB, and CSV fo…
Oct 9, 2026
4aa3766
perf(docs): update performance metrics for NativeAOT XLSX and CSV enu…
Oct 9, 2026
f90a080
feat(reader): add table part model with A1 range parsing
Oct 9, 2026
c402ebc
feat(reader): expose workbook tables on every format
Oct 9, 2026
d60068b
feat(reader): discover XLSX tables at open
Oct 9, 2026
48e0aed
feat(reader): discover XLSB tables at open
Oct 9, 2026
bdf0e2f
fix(reader): discover XLSB tables before renting shared strings
Oct 9, 2026
ed16d72
feat(reader): track absolute row index on demand in XLSX/XLSB enumera…
Oct 9, 2026
d2b30d7
feat(reader): read a table's rows through ExcelTable.AsSheet
Oct 10, 2026
a24214f
feat(reader): reject name binding on headerless tables
Oct 10, 2026
fac7938
test(fuzz): drain table views; docs: document reading Excel tables
Oct 10, 2026
1393c4c
fix(reader): map table view columns from the table's first column
Oct 10, 2026
325784b
fix(reader): reject malformed row numbers in table views
Oct 10, 2026
addef34
docs: clarify headerless-table and column-index rules for table views
Oct 10, 2026
cf27e69
fix(reader): keep XLSX rows whose open tag straddles an async refill
Oct 10, 2026
4a4519c
feat(writer): add table options, validation and tracking
Oct 10, 2026
8512d9e
feat(writer): write Excel tables to XLSX
Oct 10, 2026
528c573
test(writer): cover the async end path for XLSX tables
Oct 10, 2026
5842fa7
feat(writer): write Excel tables to XLSB
Oct 10, 2026
975d55d
feat(writer): write records as an Excel table; docs: document writing…
Oct 10, 2026
155f25b
fix(writer): drop the stray sheet AutoFilter and sort state from XLSB…
Oct 10, 2026
7634308
fix(writer): keep the reserved row of a header-only table out of the …
Oct 10, 2026
470f8c2
fix(writer): escape XLSX table column names like their header cells
Oct 10, 2026
2fd4f7a
docs: PrefetchDecompression applies to in-memory XLSX/XLSB reads
Oct 10, 2026
fa71c84
chore: add the project icon to the NuGet packages and the README
Oct 10, 2026
30edcd0
chore: show the project icon in the Python, Rust and C++ READMEs and …
Oct 10, 2026
7e0a8db
refactor!: rename the parallel record, accumulator and aggregation co…
Oct 11, 2026
97d797c
feat: add SheetParallel, a parallel reading API that takes a sheet of…
Oct 11, 2026
cd6550e
feat!: remove CsvParallel in favour of SheetParallel
Oct 11, 2026
6b49056
feat(reader): let XLSX and XLSB enumerators parse a slice of inflated…
Oct 11, 2026
ddac3c8
feat: cut an inflated sheet stream into row-aligned chunks
Oct 11, 2026
c2ec1c1
fix: stop the XLSX chunk producer at the end of sheet data
Oct 11, 2026
8aa85e8
feat: add the sheet pipeline, reading XLSX rows on several threads
Oct 11, 2026
5b3d755
feat: read XLSX sheets in parallel through SheetParallel
Oct 11, 2026
6749b9e
fix: report the sheet row for a parse failure after pipeline recovery
Oct 11, 2026
04e084c
feat: read XLSB sheets in parallel through SheetParallel
Oct 11, 2026
01369bd
feat(native): parse XLSX and XLSB typed tables in parallel
Oct 11, 2026
0696337
test(fuzz): compare the sheet pipeline with the sequential read
Oct 11, 2026
d6b1186
perf: cut sheet pipeline chunks at 256 KB instead of 1 MB
Oct 11, 2026
fa57c54
perf: benchmark parallel sheet reading and regenerate the static PGO …
Oct 11, 2026
580fba6
docs: mark the parallel benchmark tables as pending an idle rerun
Oct 11, 2026
c9c3dc6
docs: document SheetParallel and the v7 migration
Oct 11, 2026
0ffacde
fix: surface a source stream's own cancellation from the sheet pipeline
Oct 11, 2026
876d9ac
docs: correct the CSV fallback rule and sample variables
Oct 11, 2026
e56971d
fix(reader): decode OOXML _xHHHH_ escapes in XLSX text
Oct 11, 2026
60686eb
fix(reader): reject a streamed ZIP entry that ends short of its decla…
Oct 11, 2026
11eb8c8
refactor!: rename ExcelParserConfig to ExcelParserOptions
Oct 11, 2026
0ece1fe
refactor!: hand converters an ExcelRowContext instead of loose date-s…
Oct 11, 2026
042c8a3
refactor!: take options objects instead of positional optional parame…
Oct 11, 2026
30af28e
refactor!: give the XLS writer an options record and the XLSX writer …
Oct 11, 2026
fda69b8
refactor!: open CSV with ExcelReaderOptions like every other format
Oct 11, 2026
3f9fff8
docs: list the v7 options and converter-context changes in the migrat…
Oct 11, 2026
1cc50ae
refactor!: return the inferred schema as IReadOnlyList<ExcelColumnSch…
Oct 11, 2026
b2891ff
refactor!: remove Cell.TryGetDateTime(out DateTime), which assumed th…
Oct 11, 2026
b1f0980
refactor!: rename ExcelDataReader to SheetDataReader
Oct 11, 2026
584acd4
refactor!: type formula cells by their cached result and remove CellT…
Oct 11, 2026
4569c30
test(python): tie the GIL test's duration floor to the switch interval
Oct 11, 2026
fff70b3
perf: load the next litlen entry before the refill that follows a match
Oct 11, 2026
caa4038
perf(native): regenerate the static PGO profile after the inflate fas…
Oct 11, 2026
c053bfd
refactor: optimize XML parsing logic in XlsxWorkbook and XlsxXml classes
Oct 11, 2026
f3e104a
fix(rust): let docs.rs build the crate without the native library
Oct 12, 2026
79f7fda
docs(rust): use the README as the crate documentation and compile its…
Oct 12, 2026
0a02d7e
build(generator): stop adding the analyzer release files twice
Oct 12, 2026
7545986
docs(core): inherit only the summary and exceptions in BeginTableAsync
Oct 12, 2026
4e3d978
docs: fix the AOT anchor in the parsing guide
Oct 12, 2026
2d31fa7
docs: publish a docfx site with the API reference and guides
Oct 12, 2026
128fa43
docs: add a guide mapping errors across languages
Oct 12, 2026
1fb0ad1
docs(python): generate the Python API reference for the docfx site
Oct 12, 2026
30f640d
docs(cpp): generate the C++ API reference from Doxygen XML
Oct 12, 2026
776d352
docs(cpp): document the public C++ API with Doxygen comments
Oct 12, 2026
0911fdb
ci(docs): fail on Doxygen warnings and build the docs on pull requests
Oct 12, 2026
58a279b
Enhance documentation with cross-references and clarifications
Oct 12, 2026
c957491
Add snippets for CSV reading, writing, and encryption examples
Oct 12, 2026
0fde713
ci(docs): build generator project before site generation to resolve a…
Oct 12, 2026
53e31e8
docs: add guidance for reading untrusted files and implement related …
Oct 12, 2026
405e195
docs: clarify limits for decompressed bytes in untrusted file handlin…
Oct 12, 2026
518c2d7
ci: optimize workflow triggers by limiting branches for benchmarks an…
Oct 12, 2026
b796cfc
ci: disable analyzers during builds to improve performance across wor…
Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 4 additions & 3 deletions .github/workflows/benchmark-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
benchmarks: ${{ steps.filter.outputs.benchmarks }}
run: ${{ steps.filter.outputs.benchmarks == 'true' && (github.base_ref == 'master' || contains(github.event.pull_request.labels.*.name, 'run-benchmarks')) }}
# Opt-in on every base branch: the suite is ~40 runner-minutes, too much to repeat on each push.
run: ${{ steps.filter.outputs.benchmarks == 'true' && contains(github.event.pull_request.labels.*.name, 'run-benchmarks') }}
steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down Expand Up @@ -66,7 +67,7 @@ jobs:
cache-dependency-path: '**/*.csproj'

- name: Build (Release)
run: dotnet build tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj --configuration Release
run: dotnet build tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj --configuration Release -p:RunAnalyzers=false

- name: Check group coverage
run: bash tests/ExcelReader.Benchmarks/check-benchmark-groups.sh
Expand Down Expand Up @@ -94,7 +95,7 @@ jobs:
run: dotnet restore tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj

- name: Build (Release)
run: dotnet build tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj --configuration Release --no-restore
run: dotnet build tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj --configuration Release --no-restore -p:RunAnalyzers=false

- name: Run benchmarks
run: |
Expand Down
15 changes: 11 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
dotnet:
- 'src/**'
- 'tests/**'
- 'docs/snippets/**'
- 'Directory.Build.props'
- 'global.json'
- 'ExcelReader.slnx'
Expand Down Expand Up @@ -64,6 +65,12 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-latest]
env:
RUN: ${{ needs.changes.outputs.dotnet }}
# The whole solution compiles the same on every OS, so Linux builds all of it and the others
# build only what the tests need.
BUILD_TARGET: ${{ matrix.os == 'ubuntu-latest' && 'ExcelReader.slnx' || 'tests/ExcelReader.Tests/ExcelReader.Tests.csproj' }}
# Analyzers are about two thirds of the compile and report the same on every OS, so only the
# Linux build runs them; every other build in CI skips them. Source generators still run.
BUILD_ARGS: ${{ matrix.os != 'ubuntu-latest' && '-p:RunAnalyzers=false' || '' }}

steps:
- name: Nothing to build
Expand All @@ -84,11 +91,11 @@ jobs:

- name: Restore
if: env.RUN == 'true'
run: dotnet restore ExcelReader.slnx
run: dotnet restore ${{ env.BUILD_TARGET }}

- name: Build (Release)
if: env.RUN == 'true'
run: dotnet build ExcelReader.slnx --configuration Release --no-restore -p:DeterministicSourcePaths=false
run: dotnet build ${{ env.BUILD_TARGET }} --configuration Release --no-restore -p:DeterministicSourcePaths=false ${{ env.BUILD_ARGS }}

- name: Verify formatting (whitespace)
if: env.RUN == 'true' && matrix.os == 'ubuntu-latest'
Expand Down Expand Up @@ -155,7 +162,7 @@ jobs:
cache-dependency-path: '**/*.csproj'

- name: Build (Release)
run: dotnet build tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release
run: dotnet build tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release -p:RunAnalyzers=false

- name: Test (Release)
run: dotnet test --project tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release --no-build
Expand Down Expand Up @@ -188,7 +195,7 @@ jobs:
- name: Pack and verify the generator ships inside the nupkg
shell: pwsh
run: |
dotnet pack src/ExcelReader.Core/ExcelReader.Core.csproj -c Release -o ./artifacts
dotnet pack src/ExcelReader.Core/ExcelReader.Core.csproj -c Release -o ./artifacts -p:RunAnalyzers=false
$pkg = Get-ChildItem ./artifacts/*.nupkg | Select-Object -First 1
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($pkg.FullName)
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: CodeQL

on:
# develop is covered by its pull request into master; a push trigger there would run everything twice.
push:
branches: [ master, develop ]
branches: [ master ]
pull_request:
branches: [ master, develop ]
types: [ opened, synchronize, reopened, ready_for_review ]
Expand Down Expand Up @@ -89,9 +90,14 @@ jobs:
languages: ${{ matrix.language }}
queries: security-extended

# Only the shipped projects: tests, benchmarks and the fuzz harness double the build for code
# no one runs against untrusted input.
- name: Build (Release)
if: matrix.language == 'csharp'
run: dotnet build ExcelReader.slnx --configuration Release
run: |
for project in src/*/*.csproj; do
dotnet build "$project" --configuration Release -p:RunAnalyzers=false
done

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
Expand Down
74 changes: 74 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Docs -> GitHub Pages

on:
push:
branches: [master]
paths: &docs-paths
- 'src/ExcelReader.Core/**'
- 'src/ExcelReader.Arrow/**'
- 'src/ExcelReader.Native/include/**'
- 'python/src/**'
- 'python/scripts/api_markdown.py'
- 'cpp/scripts/api_markdown.py'
- 'docs/**'
- '**.md'
- 'docfx.json'
- 'assets/**'
- '.github/workflows/docs.yml'
# Builds without publishing, so a broken page or a Doxygen @param that no longer matches its
# signature fails the pull request instead of the next publish.
pull_request:
paths: *docs-paths
workflow_dispatch:

concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: write

jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json

# The snippets themselves are compiled by ci.yml, which builds the whole solution.
- name: Check guide examples match docs/snippets
run: python3 docs/snippets/sync.py --check

# Importing excelreader loads the native library lazily, so this needs only the sources.
- name: Build Python API reference
run: python3 python/scripts/api_markdown.py

# Fails on any Doxygen documentation warning, such as an @param naming no parameter.
- name: Build C++ API reference
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends doxygen
python3 cpp/scripts/api_markdown.py

# Fails on a broken page link or an xref naming a member that no longer exists. Core loads the
# generator as an analyzer from its bin/Release output, so it is built first, or docfx warns
# FailedToResolveAnalyzer.
- name: Build site
run: |
dotnet build src/ExcelReader.Generator/ExcelReader.Generator.csproj --configuration Release -p:RunAnalyzers=false
dotnet tool install --global docfx --version 2.81.0
docfx docfx.json --warningsAsErrors

# keep_files leaves dev/bench (benchmark-pages.yml) in place; pages removed from the docs
# stay on the branch until deleted by hand.
- name: Publish to gh-pages
if: github.event_name != 'pull_request'
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs/_site
publish_branch: gh-pages
keep_files: true
7 changes: 4 additions & 3 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
strategy:
fail-fast: false
matrix:
target: [xlsx, xlsx-memory, xlsx-differential, xlsb, xlsb-memory, xlsb-differential, xls, encrypted, csv, csv-sniff, csv-parallel]
target: [xlsx, xlsx-memory, xlsx-differential, xlsx-pipeline, xlsb, xlsb-memory, xlsb-differential, xlsb-pipeline, xls, encrypted, csv, csv-sniff, csv-parallel, inflate]
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -76,7 +76,7 @@ jobs:

- name: Publish harness
run: |
dotnet publish tests/ExcelReader.Fuzz/ExcelReader.Fuzz.csproj -c Release -o fuzz-out
dotnet publish tests/ExcelReader.Fuzz/ExcelReader.Fuzz.csproj -c Release -o fuzz-out -p:RunAnalyzers=false
chmod +x fuzz-out/ExcelReader.Fuzz

- name: Generate seed corpus
Expand Down Expand Up @@ -110,6 +110,7 @@ jobs:
xls) dict=xls ;;
encrypted) dict=encrypted ;;
csv*) dict=csv ;;
inflate) dict=inflate ;;
esac
./libfuzzer-dotnet \
--target_path=fuzz-out/ExcelReader.Fuzz \
Expand Down Expand Up @@ -158,7 +159,7 @@ jobs:

- name: Build
if: env.RUN == 'true'
run: dotnet build tests/ExcelReader.Fuzz/ExcelReader.Fuzz.csproj -c Release
run: dotnet build tests/ExcelReader.Fuzz/ExcelReader.Fuzz.csproj -c Release -p:RunAnalyzers=false

- name: Generate corpus
if: env.RUN == 'true'
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/native-bindings.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: Native bindings

on:
# develop is covered by its pull request into master; a push trigger there would run everything twice.
push:
branches: [master, develop]
branches: [master]
pull_request:
branches: [master, develop]
types: [opened, synchronize, reopened, ready_for_review]
Expand Down Expand Up @@ -76,6 +77,8 @@ jobs:

- name: Build the native library
shell: bash
env:
RunAnalyzers: 'false'
run: python python/scripts/build_native.py

- name: Upload native library
Expand Down Expand Up @@ -145,13 +148,17 @@ jobs:
EXCELREADER_NATIVE_LIB_DIR: ${{ github.workspace }}/${{ steps.lib.outputs.dir }}
run: cargo test --workspace --all-features --verbose

# The next two compile the crate again (default features, then the bench profile); that
# catches nothing platform-specific the test build above missed, so one OS is enough.
- name: cargo build (default features)
if: runner.os == 'Linux'
working-directory: rust
env:
EXCELREADER_NATIVE_LIB_DIR: ${{ github.workspace }}/${{ steps.lib.outputs.dir }}
run: cargo build --workspace --verbose

- name: cargo bench (smoke)
if: runner.os == 'Linux'
working-directory: rust
env:
EXCELREADER_NATIVE_LIB_DIR: ${{ github.workspace }}/${{ steps.lib.outputs.dir }}
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,6 @@ jobs:
grep -q 'already exists' <<<"$out" || exit 1
fi

# The workspace config points EXCELREADER_NATIVE_LIB_DIR at a local build that this job never makes;
# without it the package verification downloads the native library from the release, as users do.
- name: Publish excelreader to crates.io
working-directory: rust/excelreader
env:
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -506,3 +506,8 @@ python/src/excelreader/_lib/*
build/
# Subagent-driven-development scratch (per-plan ledgers, briefs, review packages)
.superpowers/
# docfx output and the generated Python/C++ API pages (docfx.json, */scripts/api_markdown.py); CI builds and publishes it to gh-pages
docs/_site/
docs/api/
docs/python-api/
docs/cpp-api/
80 changes: 70 additions & 10 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ directly rather than looking for a commentary alongside it.

What the code deliberately does *not* carry is the evidence behind a decision — benchmark numbers,
memory-footprint measurements, approaches that were tried and rejected. That lives in
[`docs/`](docs/), and [`STYLEGUIDE.md`](STYLEGUIDE.md#comments) says why.
[`docs/`](docs/README.md), and [`STYLEGUIDE.md`](STYLEGUIDE.md#comments) says why.

## The four format families

Expand Down Expand Up @@ -47,13 +47,73 @@ source-generated map) or `ExcelParser.Build` (a runtime-configured map). Its mod
a `ref struct` model binds directly to `Cell.Value` spans — zero allocation for the container and,
for span-typed columns, for the values too. It consumes `Row`/`Cell` from any sheet uniformly.

## Parallel CSV parsing

`CsvParallel.ParseAsync<T>` (`src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsv*.cs`) is an opt-in
alternate path over the same typed-parsing machinery, for sources large enough that partitioning
pays for itself. `ParallelCsvFactory` decides eligibility — a seekable, UTF-8, large-enough source —
and falls back to the ordinary sequential `ExcelParser<T>` otherwise, so callers get identical output
either way.
## Parallel sheet reading

`SheetParallel` (`src/ExcelReader.Core/Reader/SheetParallel.cs`) reads one sheet on several threads. It is
opt-in, takes any `IExcelSheet`, validates its arguments and dispatches on the sheet's concrete type:

| sheet | engine |
|---|---|
| `CsvSheet` | the CSV engine (`ParallelCsvProcessor`, `ParallelCsvEnumerable`), described below. Its source comes from `CsvReader.TryGetChunkSource`, which the native layer also uses. |
| `XlsxSheet`, `XlsbSheet` | the pipeline engine (`src/ExcelReader.Core/Parser/SheetPipeline`) |
| anything else, and every fallback case | one sequential runner over `IExcelRowEnumerator` |

The fallback cases give the same results with no error: a degree of parallelism of 1, a sheet under 256 KB
uncompressed, XLS, a table view (`ExcelTable.AsSheet`), and a CSV that cannot be partitioned (a stream that is not a file
or a non-UTF-8 encoding). The caller owns the workbook; the engine holds a `ReaderLifetime` lease
until the task completes or the enumeration ends.

The CSV engine is not folded into a shared partition abstraction. It is tuned, and its model, a static
byte-range plan with speculative starts and re-reads, differs from the pipeline's dynamic, exact chunks. What
the two share is the contract types (`IRowRecord`, `IRowAccumulator`, `RowAggregation`, `RowAction`) and the
binders in `Parser/ParallelCsv` (`MappedProjection`, `MappedAggregation`, `MappedCallback`, `RecordAggregation`,
`RecordCallback`), which pick the parser's CSV type map for a `CsvSheet` and the general one otherwise.

### The pipeline engine

```
zip entry -> producer (1 thread) -> bounded queue -> N workers -> ordered merge
inflate, cut at the row-aligned parse + by chunk index
last row boundary chunks, indexed row action
```

Inflate is sequential, so the engine splits the work around it instead of through it. One producer thread
owns the entry stream, opened through `ZipIndex.OpenEntryStream` so the decompressed-byte limits count where
they do on the sequential path. It inflates into pooled buffers and cuts each at the last row boundary,
copying the partial tail into the next buffer; chunks default to 256 KB (`SheetPipelineSource.DefaultChunkBytes`).
In XLSX the boundary is the last `<row` followed by a name boundary, and the producer stops at the sheet-data
end tag, so the rest of the part is neither inflated nor buffered. XLSB has no sync marker, so the producer walks
every record with `Biff12RecordReader` and remembers the last `BrtRowHdr`; it stops at `BrtEndSheetData`.

Chunks go through a bounded `System.Threading.Channels` queue, about twice the worker count, so a full queue
blocks the producer and peak memory is roughly capacity times chunk size whatever the sheet's size. `dop - 1`
workers (at least one) each parse a chunk in place with the format's own enumerator over `ReadOnlyMemory<byte>`,
call `Seed()`, then run the row action for each row. Shared strings are loaded before the pipeline starts, and a
prologue reads up to `HeaderRow` with an ordinary enumerator, so header rows never reach a chunk. The engine's
one primitive is an async stream of `(index, state, failure)` in chunk order, reordered through a ring of slots as
`ParallelCsvEnumerable` does; `AggregateAsync` folds the states with `Combine`, `ForEachAsync` ignores them, and
`ParseAsync` uses a `List<T>` per chunk and yields its items.

The XLSX row boundary is a candidate, not a proof: the same bytes can sit in CDATA, a comment or malformed
markup. So every chunk enumerator reports whether it ended cleanly, on a row boundary with no row or tag cut
short. A run of clean chunks equals the sequential read by induction. A chunk that does not end cleanly stops
before delivering its cut-short row; the merge keeps that chunk's rows, cancels the pipeline and reads the rest
with one sequential enumerator, skipping the rows already delivered, and later chunks' results are dropped.
This recovery re-inflates the sheet and only happens on such sheets. It leaves `ParseAsync` and `AggregateAsync`
exact, but `ForEachAsync` may already have called back for dropped chunks, so it is exactly once only for a
well-formed XLSX sheet. XLSB frames records from the first byte as the sequential reader does, so it has no
such case.

The measured gain is bounded by inflate, which stays on one thread: see "Parallel intra-sheet parsing" in
[reader-performance.md](docs/performance/reader-performance.md). Parallel inflate would replace the producer
only; the queue, workers and merge stay.

### The CSV engine

`CsvSheet` takes the older path (`src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsv*.cs`), over the same
typed-parsing machinery, for sources large enough that partitioning pays for itself. `ParallelCsvFactory`
decides eligibility, a seekable, UTF-8, large-enough source, and falls back to the ordinary sequential
`ExcelParser<T>` otherwise, so callers get identical output either way.

When it partitions, `CsvChunkPlan` slices the data range into four chunks per worker (not one — see
the file's comments on why one-per-worker would buffer up to the whole output) and hands them out
Expand Down Expand Up @@ -96,8 +156,8 @@ Still unresolved, and untested by any of the above: whether true unordered emiss
differently. The ordered merge is the safer default and the only mode either public overload can
reach, so it stays absent a specific reason to revisit.

`CsvParallel.ForEachAsync` exposes the same partitioning to a per-row callback rather than an
accumulator type. It accepts a `ref struct` row, which the enumerating `CsvParallel.ParseAsync` cannot —
`SheetParallel.ForEachAsync` exposes the same partitioning to a per-row callback rather than an
accumulator type. It accepts a `ref struct` row, which the enumerating `SheetParallel.ParseAsync` cannot —
not because of the interface (`IAsyncEnumerable<T>`'s `T` is declared `allows ref struct` on net9.0+),
but because a row holding spans into a worker's buffer cannot cross to the consumer thread at all.

Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ rewrites — see [ARCHITECTURE.md](ARCHITECTURE.md) for the shape of the codebas
[STYLEGUIDE.md § Untrusted Input](STYLEGUIDE.md#untrusted-input) before touching a parser: every
length, offset, and size read from the file must be bounded before it drives an allocation.

- **Guide examples compile.** A C# block in `docs/guide` marked `<!-- snippet: name -->` is copied
from `#region name` in `docs/snippets`, which builds with the solution. Edit the region, then
rewrite the guides; CI fails if they differ:

```bash
python docs/snippets/sync.py
```

## Commit messages

Use [Conventional Commits](https://www.conventionalcommits.org/): `type(scope): summary`, imperative
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Meziantou.Analyzer" Version="3.0.290">
<PackageReference Include="Meziantou.Analyzer" Version="3.0.294">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
Loading
Loading