Story: “Time-box the first high-volume benchmark” (backlog/epics/05_cli_rendering_engine_and_debenu_integration.md,
5 points, spike). Target under test (project_config.md): render 100,000 records at 300 DPI in
under 10 minutes.
Status update (Sprint 3, see “Sprint 3 follow-up” section below): the finding immediately below — off track by 5x-10x+ — was accurate for the render path as it existed at the end of Sprint 2. A follow-up story (“Investigate and address high-volume render throughput degradation”) confirmed the root cause and implemented a batching mitigation; the full 100k-record benchmark was then re-run to completion (not time-boxed) and now meets the 10-minute target with margin (315 seconds, ~47.5% under budget). The narrative below is left intact as the original spike record; skip to “Sprint 3 follow-up” for the current state.
Original Sprint 2 bottom line: the render path did NOT meet the target and was not close. Throughput degraded sharply as the accumulated in-memory PDF document grew, so the small-scale numbers from Sprint 1 (392 records, ~1.8s) were not representative of high-volume behavior at all. This was a real, material risk and was escalated as a new backlog story rather than fixed silently inside that spike (out of scope per the story's own conversation notes: “a spike-style story to gather performance information, not a final optimization guarantee”).
dotnet build src/EnvelopeRenderer.Cli/EnvelopeRenderer.Cli.csproj -c Release (net10.0,
Release configuration — not dotnet run/Debug, to avoid understating a production-representative
number).key.txt
(DebenuLicenseKeyResolver, walked up from the executable directory — same resolution path a
desktop-launched render would use).sample-data/sample-envelope-template.xml
(3 <text> elements per page: 1 static, 2 dynamic columns — same template Sprint 1 verified against).sample-data/87700 - 999999 - Wilson Township.csv verbatim 256 times
(header written once): 256 * 392 = 100,352 data rows. Rows are exact repeats rather than
synthetic-but-unique data — acceptable for a throughput/bottleneck spike since neither CsvHelper
parsing nor the render merge loop branches on row content, only on column names and value
length, and the CLI's per-record work is O(1) regardless (see “Root cause” below). Not
committed to the repo (30+ MB; regenerate on demand with the one-line awk command in this
file's git history / the Day 1 scrum log, or any equivalent script).EnvelopeRenderer.Cli.exe --template sample-envelope-template.xml --csv <generated-csv> --output <path>.pdf, with DEBENU_LICENSE_KEY resolved via key.txt exactly as
production/desktop launches do.PROGRESS render ... elapsedMs=<n> completed=<n> stream (already emitted by the
CLI per CLI_CONTRACT.md — no extra instrumentation needed) and the final PDF size on success.DrawText at exact point coordinates) — there is no DPI
knob in the current text-only pipeline, so “at 300 DPI” is satisfied vacuously for this slice.
This will matter once image handling (epic 7) lands and should be re-benchmarked then.The run was allowed to proceed for 138.8 seconds of wall time and was then deliberately
stopped (time-boxed) once the throughput trend below made the outcome unambiguous — projected
full-run time is many multiples of the 10-minute target (see “Extrapolation”). No output PDF
exists for this run since Save() is only called once every record has been added (see
“Root cause”); the process was killed before reaching record 100,352, so nothing was written to
disk for this run specifically.
Representative samples from the real PROGRESS render stream (full log evidence recorded during
this spike, not fabricated):
| Elapsed | Records completed | Records/sec in the preceding ~10s window |
|---|---|---|
| 108 ms | 1 | — |
| 1,109 ms | 442 | ~399/s (first second, before the effect below kicks in) |
| 10,147 ms | 1,389 | ~105/s |
| 20,276 ms | 1,930 | ~53/s |
| 30,416 ms | 2,346 | ~41/s |
| 40,521 ms | 2,696 | ~35/s |
| 50,626 ms | 3,004 | ~30/s |
| 60,809 ms | 3,282 | ~27/s |
| 71,009 ms | 3,533 | ~25/s |
| 81,245 ms | 3,766 | ~23/s |
| 91,447 ms | 3,985 | ~22/s |
| 101,725 ms | 4,184 | ~19/s |
| 112,015 ms | 4,381 | ~19/s |
| 122,238 ms | 4,555 | ~17/s |
| 132,617 ms | 4,724 | ~16/s |
| 138,843 ms (stopped here) | 4,827 | ~15/s and still falling |
Throughput is monotonically decreasing — not a one-time warm-up cost — and had not leveled off to a stable floor by the time the run was stopped at 4,827 of 100,352 records (4.8%). Process working-set memory grew from ~63 MB to ~76 MB over the same window (modest, not itself a risk at this scale) while CPU stayed pegged at effectively one full core the whole time — this is a compute-bound slowdown, not an I/O or memory-pressure one.
To have at least one complete, verified higher-volume data point rather than relying only on extrapolation, the same scenario was also run to completion at 2,000 records (first 2,000 rows of the same generated dataset):
PROGRESS startup elapsedMs=0 completed=0
...
PROGRESS complete elapsedMs=20775 completed=2000
exit=0
%PDF-1.4 header confirmed, 2,302,387 bytes (~1.15 KB/page —
in line with Sprint 1's 392-page/~1.3 MB result, so per-page output size is not the problem).0, matching CLI_CONTRACT.md.This confirms the CLI still produces correct output at this scale — the risk is purely throughput, not correctness.
Sprint 1 Batch 2 verified 392 records in ~1.8s (~217 rec/s using the throttled progress numbers
in CLI_CONTRACT.md's smoke example). That number was accurate for its own scale but is not
representative of high-volume behavior — the degradation only becomes visible past roughly the
first 1,000–2,000 accumulated pages, well beyond what a 392-row sample template exercises. This is
exactly the kind of thing this spike exists to catch before more work lands on the render hot path
(per the Sprint 1 retrospective action item).
The CLI's own C# merge loop (RenderEngine.Render, CsvRecordSource.ReadRecords) does O(1)
work per record: CSV rows are streamed one at a time (no full-file buffering), and each record
produces a small fixed-size list of TextDraws with no data structure that grows with the number
of records processed so far, other than a Dictionary<string,int> font-handle cache keyed by
distinct font name (this template uses exactly one font, so that cache never grows past size 1).
DebenuPdfRenderer.AddPage calls a fixed, constant number of Debenu API functions per page
(NewPage/SetPageDimensions/SetFillColor/SelectFont/SetTextSize/DrawText x3) with no
loop over prior pages.
That leaves the Debenu Quick PDF Library 10.13 native document object model itself as the
strongly suspected source of the slowdown: the whole PDF is built up in memory (confirmed — no
PDF bytes are written to disk until the single SaveToFile call after every record has been
added, so a killed 100k run leaves no output file at all, as seen above) and each additional
NewPage/DrawText call appears to cost more as the number of already-added pages grows. This
is consistent with, though not proven to be, an internal data structure in the vendor DLL that is
scanned or re-walked per operation (e.g., an internal page/object list) rather than one with O(1)
amortized append. This repo has no access to Debenu's internal source to confirm further; treating
it as an external-library characteristic to design around, not something to patch, is the
appropriate scope for a spike.
Off track. Using the observed trend (throughput still falling at ~15 rec/s and not yet at a floor when stopped at record 4,827), a full 100,352-record run would take at least on the order of 45–90+ minutes — 5x to 10x+ over the 10-minute target — and possibly worse, since the curve had not plateaued. This is a material risk, not a rounding-error miss.
Logged as both a new backlog story and a technical debt entry so it survives past this spike:
backlog/epics/05_cli_rendering_engine_and_debenu_integration.md (Status: Ready). Scope:
characterize whether the degradation is genuinely Debenu-internal (e.g. by probing whether
periodic Save+reopen batching, or Debenu's AddSubsettedFont/page-count-related settings,
change the curve) and pick a concrete mitigation (candidates to evaluate, not yet decided:
batching output into multiple PDF files and concatenating, periodic incremental saves, or an
alternate Debenu API usage pattern) before any further feature work depends on rendering
1M-record jobs (project_config.md's stated ceiling).logs/technical_debt_log.md, 2026-09-14 entry — “release-quality high-volume
benchmark target is currently missed by 5-10x+ at realistic scale.”Not fixed as part of this spike, per the story's own conversation notes (“not a final optimization guarantee”) and to avoid silently absorbing a scope-changing investigation into a 5-point spike.
Both were already in place for this run (the CLI always emits PROGRESS events and always
resolves the license key via DebenuLicenseKeyResolver) and neither is a plausible contributor:
ConsoleProgressReporter) and does O(1) work per throttled write; it cannot explain a
monotonically decreasing per-record rate, since its own overhead is constant per record
(a delegate call) and near-zero when throttled.DebenuLicenseKeyResolver.Resolve) runs exactly once at startup, before
any records are processed, so it cannot affect per-record throughput at all.Both are ruled out; the bottleneck is isolated to the per-page Debenu document-building cost described above.
Story: backlog/epics/05_cli_rendering_engine_and_debenu_integration.md (8 points). This section
documents the investigation, mitigation, and final re-benchmark for that story, per its own
acceptance criteria.
A standalone probe (not checked into code/, since it duplicates DebenuPdfRenderer's per-page
Debenu call sequence purely to iterate on hypotheses quickly — kept in the dev-team's scratch
workspace for this sprint) drove the real Debenu Quick PDF Library 10.13 DLL directly, drawing the
same 3 TextDraws per page as the real template, so hypotheses could be tested in minutes instead
of tens of minutes.
Baseline reproduction (no batching), 4,000 pages: reproduced the same monotonically-decreasing curve as the original 100k-scale spike, at 25x less wall time:
| Page | Elapsed | Pages/sec in preceding window |
|---|---|---|
| 250 | 460 ms | 543.5/s |
| 1,000 | 7,683 ms | 72.4/s |
| 2,000 | 35,030 ms | 27.0/s |
| 3,000 | 80,439 ms | 18.9/s |
| 4,000 | 147,737 ms | 13.1/s |
Total: 147.7 seconds for 4,000 pages, curve shape and magnitude consistent with the original 100k spike's ~399/s -> ~15/s decay (Sprint 2 section above). This confirms the probe is representative at a scale that runs in ~2.5 minutes rather than tens of minutes, satisfying task 1.
PDFLibrary instance reset the curve?Same 4,000-page scenario, but every N pages the in-progress document was saved to its own file,
the PDFLibrary instance was released (ReleaseLibrary()), and a brand-new instance was opened
for the next batch (exactly the “hypothesis to test” named in the story's conversation notes).
Real, non-simulated runs against the actual DLL:
| Batch size | Total elapsed (4,000 pages) | Effective throughput | vs. baseline (147.7s) |
|---|---|---|---|
| No batching (baseline) | 147,737 ms | ~27/s average | 1x |
| 1,000 | 35,452 ms | ~113/s average | 4.2x faster |
| 500 | 15,999 ms | ~250/s average | 9.2x faster |
| 300 | (see 10k-page validation below) | — | — |
| 200 | 6,454 ms | ~620/s average | 22.9x faster |
Crucially, throughput resets immediately after every flush/reopen rather than continuing to decay — e.g. at batch size 500, the rate right after each reopen returns to ~500-550/s (matching the very first window of the baseline curve) before decaying again within that batch, then resets again at the next boundary. This was also validated at larger, sustained scale (10,000 pages, batch size 500): throughput stayed in a stable 180-290/s band for the entire run with no further degradation across 20 consecutive batches, confirming the reset is repeatable, not a one-time effect.
Verdict: hypothesis confirmed. The Debenu-internal-document-model theory from the Sprint 2
spike is confirmed, not merely “not ruled out” — releasing and reopening a fresh PDFLibrary
instance demonstrably and repeatably resets the per-page cost back to its initial (fast) rate, and
correctness was independently verified after each test: a fresh PDFLibrary instance loading the
final merged file back reported the expected page count every time (e.g. 4,000/4,000, 10,000/10,000).
Merging batches back together requires each new PDFLibrary instance to re-embed the template's
TrueType font from scratch (fonts are not shared across separate Debenu document instances), so
every additional batch costs extra output size, not just extra reopen time. Measured directly (10,000
pages, same template/font each time):
| Batch size | Total elapsed (add+save) | Merge time | Effective throughput | Batches | Merged file size |
|---|---|---|---|---|---|
| 1,000 | 35,452 ms* | — | ~113/s | 4 | 6,345,688 bytes |
| 500 | 40,194 ms | 4,506 ms | ~224/s | 20 | 26,411,165 bytes |
| 300 | 21,695 ms | 3,311 ms | ~400/s | 34 | 41,109,608 bytes |
| 200 | — | — | ~620/s* | — | ~23,147,239 bytes (4,000-page run) |
| 100 | 10,264 ms | 4,007 ms | ~700/s | 100 | 110,401,780 bytes |
* 1,000 and 200 rows use the 4,000-page comparison run above, scaled/noted separately since they were not re-run at 10,000 pages; all others are direct 10,000-page measurements.
The extra cost is consistently ~1.05 MB per additional batch for this template's single embedded font (confirmed by the near-exact linear fit across all four data points above), which means smaller batches buy meaningfully higher throughput but at a real, non-trivial file-size cost at scale — this is a genuine trade-off, not a free win, and matters directly for the 1,000,000-record ceiling re-assessment below.
Batch size chosen for production: 300 pages per batch (DebenuPdfRenderer.DefaultPagesPerBatch).
Rationale: keeps sustained throughput close to the ~400/s “fresh document” rate (extrapolating to
~4.2 minutes for 100,000 records, comfortably inside the 10-minute target with margin), while
keeping the font re-embed overhead at 100k scale to roughly 335 batches x ~1.05 MB =~ 350 MB, well
under the 2 GB output-size constraint. Batch size 1,000 was rejected (not enough throughput
headroom); batch sizes 100-200 were rejected despite their higher raw throughput because their
file-size overhead does not stay safely under the 2 GB cap once extrapolated to the product's
stated 1,000,000-record ceiling (see below).
DebenuPdfRenderer (code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs) now batches pages
across multiple underlying Debenu documents:
DefaultPagesPerBatch (300) pages, the in-progress document is saved to its own temp file
under %TEMP%\EnvelopeRenderer-render-<guid>\, the Debenu instance is released, and a fresh one
is opened for the next batch (font handle cache cleared, first-page-reuse state reset).Save merges every batch file, in order, into the final output path using Debenu's own
MergeFileListFast (AddToFileList + MergeFileListFast) — a real vendor merge operation, not
a byte-level PDF concatenation implemented in this repo.Save falls back
to exactly the original single SaveToFile call — no temp files, no merge step, byte-for-byte
the same code path as before this story. This was verified directly: a 5-record render with
pagesPerBatch=100 left zero batch temp directories behind.%TEMP%.IPdfRenderer's public interface (AddPage/Save) is unchanged — batching is entirely internal
to DebenuPdfRenderer; RenderEngine and the CLI's progress reporting are unaffected and required
no changes.
DebenuPdfRendererBatchingTests.cs (no license key required): guards the documented
DefaultPagesPerBatch constant against silent drift, and verifies TryCreate rejects a
non-positive batch size via ArgumentOutOfRangeException.DebenuPdfRendererIntegrationTests.cs (real Debenu DLL, soft-skips without a license key, same
pattern as the existing test):
Render_WithSmallBatchSize_MergesMultipleBatchesInCorrectOrder — 10 pages, batch size 3 (batches
of 3/3/3/1), asserts not just final page count but that page content survives the merge in
the correct order for a page at a batch boundary and the final page of a trailing partial batch.Render_WhenAllPagesFitInOneBatch_NeverCreatesBatchTempFiles — confirms the fast path leaves no
batch temp directories behind.Render_RealSampleTemplateAndCsv_ProducesAValidPdfWithOnePagePerRecord test
(392 records against the real sample CSV) now exercises the multi-batch merge path for real,
since 392 > 300 — it continues to pass, independently confirming 392/392 pages at the correct
dimensions after a real batch + merge.DEBENU_LICENSE_KEY set.Same scenario as the original spike (256 * 392 = 100,352 records, real sample template/CSV
repeated, Release build, real Debenu DLL, real license key) — this time run to completion, not
time-boxed:
PROGRESS startup elapsedMs=0 completed=0
PROGRESS render elapsedMs=203 completed=1
...
PROGRESS render elapsedMs=250807 completed=100309
PROGRESS complete elapsedMs=315083 completed=100352
exit=0
date-based measurement around the process (315s), matching the
CLI's own reported elapsedMs=315083.MergeFileListFast call merging all 335 batch files into the final output — consistent with the
10,000-page merge-time measurements above, scaled up by batch count and total size.%PDF-1.4 header confirmed, 100,352 pages confirmed independently
(fresh PDFLibrary instance, LoadFromFile + PageCount()), 416,131,375 bytes (~397 MB) —
well under the 2 GB constraint.GetPageText — all three matched the
expected source data row for that position exactly (e.g. page 100,352's text matched the CSV's
392nd/last data row, as expected since 100,352 = 256 * 392).exit=0, matching CLI_CONTRACT.md's documented success case exactly.Met, with margin. 315 seconds is ~47.5% under the 10-minute (600s) budget — roughly a 19x improvement over the original spike's extrapolated 45-90+ minute full-run estimate for the same dataset, using an evidence-based mitigation rather than raw extrapolation this time (the 100k run was executed to completion, not time-boxed or projected).
EnvelopeRenderer.Cli.exe, Release build), not just
the test suite: --help (exit 0), no-args (exit 2, unchanged error text), missing template (exit
3), and a full 392-record render with DEBENU_LICENSE_KEY set (exit 0, identical PROGRESS
event shape: one startup, throttled render lines, one complete) all match
CLI_CONTRACT.md's documented behavior exactly.CLI_CONTRACT.md changes were needed — the mitigation is entirely internal to
DebenuPdfRenderer; every documented argument, exit code, and PROGRESS event shape is
unaffected. (CLI_CONTRACT.md itself now references this file in its rendering section, no
content change required.)project_config.md's 1,000,000-record ceilingQualitative, evidence-informed re-assessment — still a known risk at that scale, but a different, better-understood one than before this story:
project_config.md (only the 100k/10-minute target and a separate “support up to 1,000,000
records” capacity statement), so this isn't a pass/fail number, but it is a large, real number an
operator would need to plan around for the biggest jobs.project_config.md).AddTrueTypeSubsettedFont
(subsetted rather than fully-embedded fonts) so each batch's font re-embed only includes the glyphs
actually used, which should shrink the ~1.05 MB per-batch cost substantially for typical
alphanumeric address data.logs/technical_debt_log.md) rather than silently absorbed,
per the same discipline the Sprint 2 spike followed.Powered by TurnKey Linux.