Skip to content

Commit 4c9d765

Browse files
authored
test(api): add parallel-session stress test (I2) (#92)
Wires up Track I2 from the v1.6.5->1.7 readiness taskboard. Drives 32 independent DocumentSession instances on a fixed-size thread pool through 4 iterations to catch race conditions in process-wide shared state (font registry, glyph cache, built-in node definitions, shape outline cache). Two assertions: 1. identicalSessionsInParallelProduceIdenticalLayoutGraphs: every parallel render produces the same layout-graph toString() as a sequential baseline. Catches any shared mutable state racing in the prepare/measure pipeline. 2. independentSessionsInParallelProduceValidPdfBytes: every PDF carries the %PDF magic, is at least 256 bytes, and size variance across threads is <256 bytes (loose enough not to lock against legitimate timestamp drift, tight enough to catch corruption). Each DocumentSession is single-threaded by contract; this test exercises a fleet of INDEPENDENT sessions, not concurrent access to a single session. ~1.6s locally; does not bloat CI.
1 parent 91e0ef8 commit 4c9d765

2 files changed

Lines changed: 204 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ JitPack continue to resolve through the existing coordinates.
3434
excluded by convention (`InternalAnnotationCoverageTest` covers those).
3535
Method-level `@since` backfill for the ~380 public methods in these
3636
packages is intentionally out of scope here and tracked separately.
37+
- **Parallel-session stress test** (Track I2). New
38+
`DocumentSessionParallelStressTest` drives 32 independent
39+
`DocumentSession` instances on a fixed-size thread pool through 4
40+
iterations and asserts (a) all parallel renders produce a layout-graph
41+
signature byte-equal to the sequential baseline — exercising the
42+
shared font registry, glyph cache, built-in node definitions, and
43+
shape-outline cache for race conditions; (b) every PDF output starts
44+
with the `%PDF` magic, is at least 256 bytes, and has size variance
45+
under 256 bytes across threads (catching corruption or rare
46+
non-determinism without locking exact byte counts that timestamps
47+
could drift). 128 + 128 = 256 renders complete in ~1.6 s locally, so
48+
the test does not bloat CI. The contract is that each
49+
`DocumentSession` is single-threaded but the process-wide machinery
50+
handles concurrent _independent_ sessions safely; this test pins that.
3751
- **`no-poi` Maven profile + CI job** (Track I1). The `poi-ooxml`
3852
dependency is declared `<optional>true</optional>` so callers that
3953
render only PDFs don't pay the ~10 MB POI footprint; this PR adds a
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package com.demcha.compose.document.api;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.dsl.DocumentDsl;
5+
import com.demcha.compose.document.style.DocumentInsets;
6+
import com.demcha.compose.document.style.DocumentTextStyle;
7+
import org.junit.jupiter.api.Test;
8+
9+
import java.util.ArrayList;
10+
import java.util.HashSet;
11+
import java.util.List;
12+
import java.util.Set;
13+
import java.util.concurrent.Callable;
14+
import java.util.concurrent.CountDownLatch;
15+
import java.util.concurrent.ExecutorService;
16+
import java.util.concurrent.Executors;
17+
import java.util.concurrent.Future;
18+
import java.util.concurrent.TimeUnit;
19+
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
22+
/**
23+
* Stress test for concurrent {@link DocumentSession} usage. Drives N
24+
* independent sessions in parallel and asserts each produces a
25+
* well-formed PDF (validates no race conditions in shared font /
26+
* registry / cache state) and that sessions seeded with identical
27+
* content produce identical layout graphs (validates layout
28+
* determinism under concurrency).
29+
*
30+
* <p>Each {@link DocumentSession} is single-threaded by contract — the
31+
* stress test exercises a fleet of <strong>independent</strong>
32+
* sessions, each owned by exactly one thread, not concurrent access to
33+
* a single session. The guarantee under test is that the
34+
* <em>process-wide</em> machinery (default font registry, glyph cache,
35+
* built-in node definitions, shape outline cache) handles concurrent
36+
* lookup safely.</p>
37+
*
38+
* <p>Tracked as Track I1 / I2 in the v1.6.6 readiness taskboard.</p>
39+
*/
40+
class DocumentSessionParallelStressTest {
41+
42+
private static final int THREAD_COUNT = 32;
43+
private static final int ITERATIONS = 4;
44+
private static final long TIMEOUT_SECONDS = 60;
45+
46+
@Test
47+
void identicalSessionsInParallelProduceIdenticalLayoutGraphs() throws Exception {
48+
// The layout graph is the canonical deterministic snapshot — PDF
49+
// bytes may differ across runs (xref hashes, resource-stream
50+
// ordering) but the layout structure must be bit-stable. If
51+
// concurrent runs ever surface a different layout-graph
52+
// toString() than the sequential baseline, we have shared
53+
// mutable state racing somewhere in the prepare/measure pipeline.
54+
for (int iteration = 0; iteration < ITERATIONS; iteration++) {
55+
String baseline = renderLayoutSignature();
56+
assertThat(baseline)
57+
.as("sequential baseline must be non-empty")
58+
.isNotBlank();
59+
60+
Set<String> signatures = runParallel(this::renderLayoutSignature);
61+
assertThat(signatures)
62+
.as("parallel iteration %d — every thread should produce the baseline layout", iteration)
63+
.containsExactly(baseline);
64+
}
65+
}
66+
67+
@Test
68+
void independentSessionsInParallelProduceValidPdfBytes() throws Exception {
69+
// Each thread builds its own document and writes a PDF. We don't
70+
// assert byte-identity — that would over-specify (PDF timestamps,
71+
// resource ordering). We assert each output starts with the PDF
72+
// magic %PDF and has plausible size, which is enough to catch
73+
// any thread that errored out or produced corrupted bytes.
74+
for (int iteration = 0; iteration < ITERATIONS; iteration++) {
75+
Set<Integer> sizes = runParallel(() -> {
76+
byte[] pdf = renderPdfBytes();
77+
assertThat(pdf)
78+
.as("each PDF must be present and non-empty")
79+
.isNotEmpty();
80+
// 256 bytes is the smallest plausible PDF — even a single-page
81+
// empty document carries header + catalog + xref + trailer well
82+
// past that. Anything smaller means the renderer truncated.
83+
assertThat(pdf.length)
84+
.as("each PDF should be at least 256 bytes")
85+
.isGreaterThan(256);
86+
assertThat(new String(pdf, 0, 4))
87+
.as("each PDF must carry the %%PDF magic")
88+
.isEqualTo("%PDF");
89+
return pdf.length;
90+
});
91+
// All identical content → byte sizes should be within a tight
92+
// range. We don't lock the exact size (CreationDate / xref
93+
// offsets can drift by a handful of bytes between threads)
94+
// but legit metadata variance never crosses ~256 bytes for a
95+
// fixed-content render; anything past that points at content
96+
// corruption. Recalibrate this threshold if a PDFBox bump
97+
// makes legitimate variance bigger.
98+
int min = sizes.stream().mapToInt(Integer::intValue).min().orElseThrow();
99+
int max = sizes.stream().mapToInt(Integer::intValue).max().orElseThrow();
100+
assertThat(max - min)
101+
.as("parallel iteration %d — PDF size variance suggests non-deterministic content (min=%d max=%d)",
102+
iteration, min, max)
103+
.isLessThan(256);
104+
}
105+
}
106+
107+
private <T> Set<T> runParallel(Callable<T> task) throws Exception {
108+
// The CountDownLatch pair forms a "start-gun" barrier so all
109+
// THREAD_COUNT workers hit the shared state in the same nanosecond
110+
// instead of trickling in over the thread-pool ramp-up. Maximises
111+
// the chance of triggering a race condition; standard pattern for
112+
// concurrent unit tests.
113+
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
114+
try {
115+
CountDownLatch ready = new CountDownLatch(THREAD_COUNT);
116+
CountDownLatch start = new CountDownLatch(1);
117+
List<Callable<T>> tasks = new ArrayList<>(THREAD_COUNT);
118+
for (int i = 0; i < THREAD_COUNT; i++) {
119+
tasks.add(() -> {
120+
ready.countDown();
121+
start.await();
122+
return task.call();
123+
});
124+
}
125+
// Submit all tasks first; they each block on `start`.
126+
List<Future<T>> futures = new ArrayList<>(THREAD_COUNT);
127+
for (Callable<T> wrapped : tasks) {
128+
futures.add(executor.submit(wrapped));
129+
}
130+
// Wait for every worker to reach the barrier, then fire.
131+
ready.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
132+
start.countDown();
133+
134+
// Collect results. A HashSet collapses identical entries —
135+
// when the test asserts containsExactly(baseline), a size-1
136+
// set means every thread agreed on the baseline value.
137+
Set<T> results = new HashSet<>();
138+
for (Future<T> future : futures) {
139+
results.add(future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
140+
}
141+
return results;
142+
} finally {
143+
executor.shutdown();
144+
executor.awaitTermination(5, TimeUnit.SECONDS);
145+
}
146+
}
147+
148+
/** Sequential render — returns the layout-graph toString for parity checks. */
149+
private String renderLayoutSignature() throws Exception {
150+
try (DocumentSession session = GraphCompose.document()
151+
.pageSize(400, 400)
152+
.margin(DocumentInsets.of(20))
153+
.create()) {
154+
155+
DocumentDsl dsl = session.dsl();
156+
dsl.pageFlow()
157+
.name("StressFlow")
158+
.module("Header", module -> module
159+
.paragraph(p -> p.text("Concurrent stress: header")
160+
.textStyle(DocumentTextStyle.DEFAULT)))
161+
.module("Body", module -> module
162+
.paragraph(p -> p.text("Lorem ipsum dolor sit amet.")
163+
.textStyle(DocumentTextStyle.DEFAULT))
164+
.paragraph(p -> p.text("Consectetur adipiscing elit.")
165+
.textStyle(DocumentTextStyle.DEFAULT)))
166+
.build();
167+
168+
return session.layoutGraph().toString();
169+
}
170+
}
171+
172+
/** Sequential render — returns the bytes of a small in-memory PDF. */
173+
private byte[] renderPdfBytes() throws Exception {
174+
try (DocumentSession session = GraphCompose.document()
175+
.pageSize(400, 400)
176+
.margin(DocumentInsets.of(20))
177+
.create()) {
178+
179+
DocumentDsl dsl = session.dsl();
180+
dsl.pageFlow()
181+
.name("StressFlow")
182+
.module("Body", module -> module
183+
.paragraph(p -> p.text("Concurrent stress: body")
184+
.textStyle(DocumentTextStyle.DEFAULT)))
185+
.build();
186+
187+
return session.toPdfBytes();
188+
}
189+
}
190+
}

0 commit comments

Comments
 (0)