Skip to content

Commit 9e87785

Browse files
committed
0.8.2: writeSeries() accumulates seriesRowCount across multiple calls
When LastraWriter.writeSeries(rowCount, ...) is called more than once on the same writer (streaming-append usage), each call previously REPLACED the total seriesRowCount instead of adding to it. The footer therefore recorded only the last call's count, and full-series reads via readSeriesLong/Double tripped ArrayIndexOutOfBoundsException because the result buffer was sized from that under-count while per-RG arraycopy fed it the full data. Single-call usage (the auto-partitioned multi-RG path already covered by testRowGroupsAutoPartitioning) is unaffected — the field starts at 0 and one call adds its rowCount, total equals rowCount. Per-RG reads (readRowGroupLong/Double, rowGroupStats(g)) were already correct in 0.8.1 — only the aggregate seriesRowCount was wrong. Tests: new testMultipleWriteSeriesCallsAccumulate (4 × writeSeries(100,...)) verifies the running total, per-RG stats per call, and concatenated full read. 26/26 suite green.
1 parent a4c6fb9 commit 9e87785

4 files changed

Lines changed: 122 additions & 2 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# 0.8.2 — writeSeries() accumulates seriesRowCount across calls
2+
3+
**Date**: 2026-05-06
4+
5+
## What
6+
7+
`LastraWriter.writeSeries(int rowCount, Object... columnData)` can be invoked
8+
multiple times to append successive chunks to the same series — each call
9+
becomes one or more row groups, and the reader sees them concatenated. Until
10+
0.8.1, however, every call **replaced** `seriesRowCount` with its own
11+
`rowCount` rather than adding to the running total. The footer therefore
12+
recorded only the last call's count.
13+
14+
Downstream effect: `LastraReader.readSeriesLong/Double` allocates the result
15+
array as `new T[seriesRowCount]`, then `arraycopy`'s every row group into it.
16+
With a buggy total, the second RG's bytes overran the array — `arraycopy`
17+
threw `ArrayIndexOutOfBoundsException`. Per-RG reads
18+
(`readRowGroupLong/Double(g, name)`) and per-RG stats (`rowGroupStats(g)`)
19+
were already correct.
20+
21+
## Fix
22+
23+
```java
24+
public LastraWriter writeSeries(int rowCount, Object... columnData) {
25+
- this.seriesRowCount = rowCount;
26+
+ this.seriesRowCount += rowCount;
27+
```
28+
29+
## Compatibility
30+
31+
- Single-call usage (the common case, including everything written by
32+
lastra-java ≤ 0.8.1) is unaffected — the field is initialised to 0, the
33+
call adds `rowCount`, total equals `rowCount`.
34+
- Files produced by 0.8.1 with multiple `writeSeries` calls have an
35+
under-counted `seriesRowCount` in the footer; those files cannot be safely
36+
fully read via `readSeriesLong/Double`. Per-RG reads still work.
37+
Re-encoding from source data through 0.8.2 produces a file with the
38+
correct total.
39+
40+
## Tests
41+
42+
New `LastraRoundtripTest.testMultipleWriteSeriesCallsAccumulate`:
43+
- 4 calls × `writeSeries(100, ts, v)` with `setRowGroupSize(100)`.
44+
- Asserts: `rowGroupCount=4`, `seriesRowCount=400`, per-RG stats bound to
45+
each call's slice, full-series read returns the 400 rows in order, and
46+
`readRowGroupLong(2, "ts")` returns the third call's 100-row slice.
47+
48+
Reproduces against 0.8.1 (`expected: 400 but was: 100`); passes on 0.8.2.
49+
50+
All 26 unit tests across the suite green on 0.8.2.

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<groupId>com.wualabs.qtsurfer</groupId>
77
<artifactId>lastra</artifactId>
8-
<version>0.8.1</version>
8+
<version>0.8.2</version>
99
<packaging>jar</packaging>
1010

1111
<name>Lastra</name>

src/main/java/com/wualabs/qtsurfer/lastra/LastraWriter.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ public LastraWriter setRowGroupSize(int size) {
115115
* {@code double[]} for DOUBLE, {@code byte[][]} for BINARY.
116116
*/
117117
public LastraWriter writeSeries(int rowCount, Object... columnData) {
118-
this.seriesRowCount = rowCount;
118+
// Accumulate across calls — each invocation appends rows to the same series rather
119+
// than replacing it. The footer's series row count must reflect the total written.
120+
this.seriesRowCount += rowCount;
119121

120122
// Partition into row groups
121123
for (int start = 0; start < rowCount; start += rowGroupSize) {

src/test/java/com/wualabs/qtsurfer/lastra/LastraRoundtripTest.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,74 @@ void testCloseIsIdempotent() throws Exception {
476476
assertThat(r.readSeriesLong("ts")).containsExactly(ts);
477477
}
478478

479+
/**
480+
* Streaming-append usage: caller invokes {@link LastraWriter#writeSeries} multiple times,
481+
* each call carrying the next chunk of the series. The single-call variant is already
482+
* covered by {@link #testRowGroupsAutoPartitioning} (one writeSeries → auto-partitioned
483+
* into N RGs by rgSize). This case is the dual: K calls, each {@code writeSeries(rgSize)}
484+
* produces one RG, and the reader sees K RGs whose concatenated content equals the union.
485+
*
486+
* <p>Verifies on the writer side: {@link LastraReader#seriesRowCount} reports the SUM of
487+
* rows across calls (not just the last call), {@link LastraReader#rowGroupCount} reports K,
488+
* each RG's stats are bound to its slice, and {@link LastraReader#readSeriesLong} /
489+
* {@code readSeriesDouble} return all K*rgSize values in chronological order.
490+
*/
491+
@Test
492+
void testMultipleWriteSeriesCallsAccumulate() throws Exception {
493+
int rgSize = 100;
494+
int rgCount = 4;
495+
long t0 = 1_700_000_000_000L;
496+
long hourMs = 3_600_000L;
497+
498+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
499+
try (LastraWriter w = new LastraWriter(baos)) {
500+
w.addSeriesColumn("ts", Lastra.DataType.LONG, Lastra.Codec.DELTA_VARINT);
501+
w.addSeriesColumn("v", Lastra.DataType.DOUBLE, Lastra.Codec.ALP);
502+
w.setRowGroupSize(rgSize);
503+
for (int g = 0; g < rgCount; g++) {
504+
long[] ts = new long[rgSize];
505+
double[] v = new double[rgSize];
506+
for (int i = 0; i < rgSize; i++) {
507+
ts[i] = t0 + g * hourMs + i;
508+
v[i] = 100.0 + g + i * 0.01;
509+
}
510+
w.writeSeries(rgSize, ts, v);
511+
}
512+
}
513+
514+
LastraReader r = LastraReader.from(baos.toByteArray());
515+
int totalRows = rgCount * rgSize;
516+
517+
assertThat(r.rowGroupCount()).as("rgCount").isEqualTo(rgCount);
518+
assertThat(r.seriesRowCount())
519+
.as("seriesRowCount must be the sum across writeSeries() calls")
520+
.isEqualTo(totalRows);
521+
522+
// Per-RG stats: each call produces one RG with its own time bounds.
523+
for (int g = 0; g < rgCount; g++) {
524+
RowGroupStats s = r.rowGroupStats(g);
525+
assertThat(s.rowCount()).as("rg %d rowCount", g).isEqualTo(rgSize);
526+
assertThat(s.tsMin()).as("rg %d tsMin", g).isEqualTo(t0 + g * hourMs);
527+
assertThat(s.tsMax()).as("rg %d tsMax", g).isEqualTo(t0 + g * hourMs + (rgSize - 1));
528+
}
529+
530+
// Whole-series read returns all rows from all RGs in order.
531+
long[] ts = r.readSeriesLong("ts");
532+
double[] v = r.readSeriesDouble("v");
533+
assertThat(ts).hasSize(totalRows);
534+
assertThat(v).hasSize(totalRows);
535+
assertThat(ts[0]).isEqualTo(t0);
536+
assertThat(ts[rgSize]).as("first ts of RG #2").isEqualTo(t0 + hourMs);
537+
assertThat(ts[totalRows - 1]).isEqualTo(t0 + (rgCount - 1) * hourMs + (rgSize - 1));
538+
assertThat(v[0]).isEqualTo(100.0);
539+
assertThat(v[totalRows - 1]).isEqualTo(100.0 + (rgCount - 1) + (rgSize - 1) * 0.01);
540+
541+
// Per-RG read returns exactly that RG's slice.
542+
long[] rg2Ts = r.readRowGroupLong(2, "ts");
543+
assertThat(rg2Ts).hasSize(rgSize);
544+
assertThat(rg2Ts[0]).isEqualTo(t0 + 2 * hourMs);
545+
}
546+
479547
private static void assertBitExact(double[] actual, double[] expected) {
480548
assertThat(actual).hasSize(expected.length);
481549
for (int i = 0; i < expected.length; i++) {

0 commit comments

Comments
 (0)