Skip to content

Commit 0a4237f

Browse files
committed
test(api): add parallel-session stress test (I2)
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 0a4237f

2 files changed

Lines changed: 174 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: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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.ExecutorService;
15+
import java.util.concurrent.Executors;
16+
import java.util.concurrent.Future;
17+
import java.util.concurrent.TimeUnit;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
/**
22+
* Stress test for concurrent {@link DocumentSession} usage. Drives N
23+
* independent sessions in parallel and asserts each produces a
24+
* well-formed PDF (validates no race conditions in shared font /
25+
* registry / cache state) and that sessions seeded with identical
26+
* content produce identical layout graphs (validates layout
27+
* determinism under concurrency).
28+
*
29+
* <p>Each {@link DocumentSession} is single-threaded by contract — the
30+
* stress test exercises a fleet of <strong>independent</strong>
31+
* sessions, each owned by exactly one thread, not concurrent access to
32+
* a single session. The guarantee under test is that the
33+
* <em>process-wide</em> machinery (default font registry, glyph cache,
34+
* built-in node definitions, shape outline cache) handles concurrent
35+
* lookup safely.</p>
36+
*
37+
* <p>Tracked as Track I1 / I2 in the v1.6.6 readiness taskboard.</p>
38+
*/
39+
class DocumentSessionParallelStressTest {
40+
41+
private static final int THREAD_COUNT = 32;
42+
private static final int ITERATIONS = 4;
43+
private static final long TIMEOUT_SECONDS = 60;
44+
45+
@Test
46+
void identicalSessionsInParallelProduceIdenticalLayoutGraphs() throws Exception {
47+
// The layout graph is the canonical deterministic snapshot — PDF
48+
// bytes may differ across runs (xref hashes, resource-stream
49+
// ordering) but the layout structure must be bit-stable. If
50+
// concurrent runs ever surface a different layout-graph
51+
// toString() than the sequential baseline, we have shared
52+
// mutable state racing somewhere in the prepare/measure pipeline.
53+
for (int iteration = 0; iteration < ITERATIONS; iteration++) {
54+
String baseline = renderLayoutSignature();
55+
assertThat(baseline)
56+
.as("sequential baseline must be non-empty")
57+
.isNotBlank();
58+
59+
Set<String> signatures = runParallel(this::renderLayoutSignature);
60+
assertThat(signatures)
61+
.as("parallel iteration %d — every thread should produce the baseline layout", iteration)
62+
.containsExactly(baseline);
63+
}
64+
}
65+
66+
@Test
67+
void independentSessionsInParallelProduceValidPdfBytes() throws Exception {
68+
// Each thread builds its own document and writes a PDF. We don't
69+
// assert byte-identity — that would over-specify (PDF timestamps,
70+
// resource ordering). We assert each output starts with the PDF
71+
// magic %PDF and has plausible size, which is enough to catch
72+
// any thread that errored out or produced corrupted bytes.
73+
for (int iteration = 0; iteration < ITERATIONS; iteration++) {
74+
Set<Integer> sizes = runParallel(() -> {
75+
byte[] pdf = renderPdfBytes();
76+
assertThat(pdf)
77+
.as("each PDF must be present and non-empty")
78+
.isNotEmpty();
79+
assertThat(pdf.length)
80+
.as("each PDF should be at least 256 bytes")
81+
.isGreaterThan(256);
82+
assertThat(new String(pdf, 0, 4))
83+
.as("each PDF must carry the %%PDF magic")
84+
.isEqualTo("%PDF");
85+
return pdf.length;
86+
});
87+
// All identical content → byte sizes should be within a tight
88+
// range. We don't lock the exact size (timestamps can drift)
89+
// but they shouldn't vary wildly — anything beyond a few
90+
// percent points at content corruption.
91+
int min = sizes.stream().mapToInt(Integer::intValue).min().orElseThrow();
92+
int max = sizes.stream().mapToInt(Integer::intValue).max().orElseThrow();
93+
assertThat(max - min)
94+
.as("parallel iteration %d — PDF size variance suggests non-deterministic content (min=%d max=%d)",
95+
iteration, min, max)
96+
.isLessThan(256);
97+
}
98+
}
99+
100+
private <T> Set<T> runParallel(Callable<T> task) throws Exception {
101+
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
102+
try {
103+
List<Callable<T>> tasks = new ArrayList<>(THREAD_COUNT);
104+
for (int i = 0; i < THREAD_COUNT; i++) {
105+
tasks.add(task);
106+
}
107+
List<Future<T>> futures = executor.invokeAll(tasks, TIMEOUT_SECONDS, TimeUnit.SECONDS);
108+
Set<T> results = new HashSet<>();
109+
for (Future<T> future : futures) {
110+
results.add(future.get());
111+
}
112+
return results;
113+
} finally {
114+
executor.shutdownNow();
115+
}
116+
}
117+
118+
/** Sequential render — returns the layout-graph toString for parity checks. */
119+
private String renderLayoutSignature() throws Exception {
120+
try (DocumentSession session = GraphCompose.document()
121+
.pageSize(400, 400)
122+
.margin(DocumentInsets.of(20))
123+
.create()) {
124+
125+
DocumentDsl dsl = session.dsl();
126+
dsl.pageFlow()
127+
.name("StressFlow")
128+
.module("Header", module -> module
129+
.paragraph(p -> p.text("Concurrent stress: header")
130+
.textStyle(DocumentTextStyle.DEFAULT)))
131+
.module("Body", module -> module
132+
.paragraph(p -> p.text("Lorem ipsum dolor sit amet.")
133+
.textStyle(DocumentTextStyle.DEFAULT))
134+
.paragraph(p -> p.text("Consectetur adipiscing elit.")
135+
.textStyle(DocumentTextStyle.DEFAULT)))
136+
.build();
137+
138+
return session.layoutGraph().toString();
139+
}
140+
}
141+
142+
/** Sequential render — returns the bytes of a small in-memory PDF. */
143+
private byte[] renderPdfBytes() throws Exception {
144+
try (DocumentSession session = GraphCompose.document()
145+
.pageSize(400, 400)
146+
.margin(DocumentInsets.of(20))
147+
.create()) {
148+
149+
DocumentDsl dsl = session.dsl();
150+
dsl.pageFlow()
151+
.name("StressFlow")
152+
.module("Body", module -> module
153+
.paragraph(p -> p.text("Concurrent stress: body")
154+
.textStyle(DocumentTextStyle.DEFAULT)))
155+
.build();
156+
157+
return session.toPdfBytes();
158+
}
159+
}
160+
}

0 commit comments

Comments
 (0)