Repository navigation
Conversation
f56f2f7 to
851e1c7
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #19035 +/- ##
============================================
+ Coverage 47.97% 48.02% +0.04%
Complexity 604 604
============================================
Files 5571 5571
Lines 409682 409732 +50
Branches 54009 54024 +15
============================================
+ Hits 196560 196779 +219
+ Misses 213122 212953 -169 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
SeriesScanUtil expands the pages of every overlapping sequence chunk into seqPageReaders and then assumes the head of that list is the next page in scan order: firstPageOverlapped() only compares the current page with seqPageReaders.get(0), and getFirstPageReaderFromCachedReaders() only returns that same head. That holds while sequence files do not overlap. With overlapping sequence files the pages of the later file are appended after the pages of the earlier one, so a page starting before the head is not examined until the earlier file is exhausted. Its rows have been returned by then, so they come back a second time and out of time order. Insert page readers in scan order instead. The unsequence side already uses an ordered priority queue; with non-overlapping files every insert lands at the tail, so the existing order is unchanged and the only cost is one comparison per page.
35286ac to
077029a
Compare
DataRegion.split() classified the rows of a tablet by latching the sequence flag: once a row was newer than the device's last flush time, every following row of that device was written into the sequence space as well. That only holds while the rows are sorted by time, an expectation the engine does not verify - its own INSERT INTO ... SELECT path feeds the storage layer with rows in the child operator's output order. With an out-of-order tablet such as [100, 15] for a device whose last flushed time is 39, the older row was written into a new sequence TsFile, which then overlapped the existing sequence TsFile of the same device. Subsequent queries returned duplicated rows and broke the time order, without any error. Track whether the rows are still in ascending time order and, when the order is broken, classify each remaining row against the device's last flush time individually. Sorted tablets keep the previous behaviour and cost only one extra comparison per row. Fixes #19020
The storage layer assumes the rows of a tablet are sorted by time. Several places rely on it: the sequence/unsequence classification, the time boundaries of the tsfile resource, the ttl check, the time partition routing and the composition of the last value. The child operator of an INSERT ... SELECT does not guarantee that order, so the engine can feed itself an unsorted tablet and get any of those wrong. Sort the rows (stably, by time) when the tablet statement is built, permuting the value columns and the null bitmaps along with the timestamps. A tablet that is already sorted - the normal case - is left untouched after one comparison per row.
The regression tests added by the previous commits pass on the fixed code, but some of them could not fail on the unfixed code, so tighten the oracles: - DataRegionTest now reads the chunk statistics of the files actually written instead of trusting the device time index. The index is updated from the first and last row of each inserted range, so the unfixed storage code indexes the offending sequence file as [100, 100] although it physically holds 15 and 100, and the resource-index based overlap check is blind to it. The new assertion fails on the unfixed code with "startTime=15, endTime=100, count=2". - Document that the end-to-end IT depends on the nested-loop cross join emitting [100, 15], and name the guards that do not depend on the plan shape. - Cover the descending scan of a fully contained page overlap and the streaming mem chunk branch of unpackOneFakeMemChunkMetaData. - Cover the full-batch path of the statement generator (the arrays are handed over without being copied), mixed column types, tag column permutation including a null tag, and generator reuse after reset().
- IoTDBInsertQueryIT: lower max_number_of_points_in_page to 10 so the first sequence file has several pages. With a single page the read side merges the overlapping page before the earlier pages are emitted and the end-to-end test passes on the unpatched code. Also check the descending scan order. - InsertTabletStatementGeneratorTest: rows sharing a timestamp keep their relative order, and aligned tablets are sorted as well. - DataRegionTest: a sequence/unsequence/sequence split after an inversion keeps the sequence space non-overlapped and routes only the inverted row to the unsequence space. - OverlappingSeqChunkScanTest: a later file with several overlapping pages, in both scan directions.
The descending result starts with the latest row 100 and then continues with 39 down to 10, but the expected list was built with a loop from 100 down to 10, i.e. 91 values instead of 31. The ascending assertion was unaffected.
|
JackieTien97
left a comment
There was a problem hiding this comment.
Thanks for the detailed root-cause analysis and the regression tests. I left a few inline comments, mostly about the cost the new sort adds to INSERT INTO ... SELECT and about how much of the problem the storage-layer guard actually covers. The numbers below were measured locally on the PR head (7f12bc27af; JDK 21, warmed-up JIT, 10,000-row batches).
| int length = Array.getLength(column); | ||
| Object sortedColumn = Array.newInstance(column.getClass().getComponentType(), length); | ||
| for (int i = 0; i < length; i++) { | ||
| Array.set(sortedColumn, i, Array.get(column, permutation[i])); |
There was a problem hiding this comment.
Array.get / Array.set box every primitive element and make a native call per element, so the unsorted path is much more expensive than it needs to be. Measured constructInsertTabletStatement() on the PR head (10,000-row batch, full-batch path):
| columns | already sorted | unsorted (this PR) | same algorithm, typed copy loops |
|---|---|---|---|
| 1 tag + 10 DOUBLE | 0.004 ms | 14.0 ms | 0.56 ms |
| 1 tag + 50 DOUBLE | 0.004 ms | 64.0 ms | 2.19 ms |
The sort runs synchronously on the INTO operator's driver thread before the write is submitted, so it doesn't overlap with the write, and every batch coming from ORDER BY time DESC, a join, or a multi-device table scan takes this path.
Could the copy switch on the column's TSDataType and use typed loops instead? The Integer[] index sort itself is cheap (the typed variant above still uses it); the reflective copy dominates. If you also want to drop the boxing, Session#sortedIndex has a stable primitive merge sort with a fast path for strictly descending input.
| order[i] = i; | ||
| } | ||
| // stable, so rows sharing a timestamp keep their relative order | ||
| Arrays.sort(order, Comparator.comparingLong(index -> times[index])); |
There was a problem hiding this comment.
For the table model one generator covers the whole target table, so a tablet usually holds several devices. The child operator emits them grouped by device, each device already in ascending time order, which is what the storage layer actually relies on: split, the device time index and the last cache all work per device range. A global stable sort by time interleaves those devices, so splitByDevice goes from one segment per device to up to one segment per row.
DataRegion#insertTablet alone, 10,000-row relational tablets, device-grouped (what master writes) vs. time-interleaved (what this PR writes):
| devices × rows per device | device segments | grouped | interleaved |
|---|---|---|---|
| 1000 × 10 | 1000 → 10000 | 2.4 ms | 6.8–7.9 ms |
| 100 × 100 | 100 → 10000 | 1.45 ms | 6.06 ms |
| 10 × 1000 | 10 → 10000 | 1.22 ms | 5.6 ms |
The coordinator side (collectSplitRanges / generateOneSplitList) and updateLastCache also scale with the number of segments and are not included above. Downsampling statements such as INSERT INTO agg SELECT date_bin(1h, time), tag, avg(s) FROM src GROUP BY 1, tag hit this on every batch.
If the goal is only the out-of-order rows of a single device, sorting only when some device's rows go backwards (or sorting by (device, time)) would keep the grouping. There is a trade-off worth spelling out, though. From reading the code (not verified with a test), two consumers depend on a globally sorted tablet:
PipeInsertNodeTabletInsertionEvent#mayEventTimeOverlappedWithTimeRangeonly looks at the first and last timestamps.- The relational TTL check marks expired rows individually, but
splitAndInsertwrites withnoFailure = true, which passesnullresults to the memtable, so those rows are not skipped.
Both look wrong on master for device-grouped tablets, and the global sort happens to fix them. Could the PR either keep the device grouping and handle those two consumers, or keep the global sort and call out the extra write cost?
| beforeTimePartition = timePartitionId; | ||
| isSequence = time > lastFlushTime; | ||
| } else if (!isSequence) { | ||
| } else if (!isSequence || !timeOrdered) { |
There was a problem hiding this comment.
This branch only moves rows that are not newer than the last flushed time into the unsequence space. Rows that are all newer than it, but out of order among themselves, still land in one sequence range, and TsFileProcessor#insertTablet takes the device start time from the first row of each range, so the file's time index is still wrong.
Reproduced on the PR head: write 10..39 for a device and close the file (last flushed time 39), then write the unsorted tablet [100, 50] and close again. The second sequence file is indexed [100, 100] while its chunk statistics are [50, 100] with 2 points, and no unsequence file is created. A query with WHERE time <= 60 is then pruned by the resource time index and misses the row at 50.
Not every ingress sorts before reaching this point: the REST handlers (StatementConstructionHandler under rest/protocol/table/v1 and rest/protocol/v2) and MQTT (MPPPublishHandler) build the InsertTabletStatement as received. For those paths the TTL prefix, the forward-only partition detection in collectSplitRanges / getTimePartitionSlots, and the last-row last cache are unchanged as well. So "Fixing it in the storage layer covers every ingress" in the description doesn't quite hold, and the description still lists sorting as an alternative not taken, while a later commit sorts in the generator.
Would it be simpler to do the O(n) sortedness check, and the sort when needed, once at a common point for every InsertTabletStatement (e.g. during analysis, before the time partition slots are computed)? That would cover INTO, REST and MQTT in one place, and this branch could remain a safety net.
| * INTO ... SELECT} does not guarantee that order, so restore it before the tablet leaves the | ||
| * engine. | ||
| */ | ||
| private void sortRowsByTimeIfNeeded() { |
There was a problem hiding this comment.
The datanode already has this: new PipeTableModelTabletEventSorter(statement).sortByTimestampIfNecessary() (used by PipeTransferTabletRawReqV2) does the same sortedness check, a stable sort by time, and a typed reorder of the values and bitmaps, including the handling of how DATE columns are stored. Could this reuse it, or move the core of PipeInsertEventSorter into a shared utility so the query engine doesn't depend on the pipe package? Otherwise the two copies have to stay consistent on stability, bitmaps and DATE columns.
| // the same partition and switch to sequence data | ||
| // insert previous range into unsequence | ||
| updateSplitInfo(splitInfo, beforeTimePartition, false, new int[] {before, loc}); | ||
| boolean rowIsSequence = time > lastFlushTime; |
There was a problem hiding this comment.
Minor: once timeOrdered is false, every remaining row calls getLastFlushTime(...) (a HashMap<Long, …> lookup with boxing plus a device lookup) under the write lock. split is called per device range and the partition only changes in the first branch, so the value is constant until the partition changes. It could be looked up once per partition and reused, which would also help the existing !isSequence prefix. This only affects unsorted input.
| } | ||
| } | ||
|
|
||
| sortRowsByTimeIfNeeded(); |
There was a problem hiding this comment.
Minor: when rowCount != rowLimit, the arrays are first truncated by the copy above, and sortRowsByTimeIfNeeded() then allocates every column again. While an unsorted batch is sorted, two copies of the times, columns and bitmaps are alive, plus the boxed Integer[] index and the int[] / long[] buffers. None of that is in ramBytesUsed(), and the row limit derived from into_operation_buffer_size_in_byte assumes a single copy. For wide tables, applying the permutation while truncating would do both in one copy.
| // The out-of-order tablet test needs a sequence file with several pages: when the earlier | ||
| // file only has one page, the read side merges the overlapping page before the earlier | ||
| // pages are emitted and the wrong order is masked. | ||
| .setMaxNumberOfPointsInPage(10); |
There was a problem hiding this comment.
The 10-point page is only needed so this test can fail on the unfixed code, but the cluster is shared, so testSimple and the other existing tests in this class now also run with 10-point pages, which changes their storage layout and coverage. Would a separate IT class for the new test be better, or a comment explaining why the whole class needs this setting?




Problem
Fixes #19020.
A tablet whose rows are not in ascending time order could write a row older than the device's last flush time into the sequence space. The resulting sequence TsFile overlapped the existing sequence TsFile of the same device, and plain
SELECTqueries then returned duplicated rows and broke the time order, silently — no exception, no log entry.A pure-SQL reproduction (create the layout, insert an out-of-order tablet through
INSERT INTO ... SELECT, then observe the wrong order) is in the issue.The state is reachable through the engine's own SQL path:
TableInsertTabletStatementGeneratorbuilds the insert tablet in the child operator's output order and does not sort it, while the storage layer documents that it assumes the batch has been sorted by the client (InsertTabletNode.checkTTLInternal).Root cause
Three defects combine: the engine feeds itself an unsorted tablet, the write side turns that into an overlapping file, and the read side turns the file into a wrong result.
1. The tablet reaches storage unsorted
INSERT INTO ... SELECTcopies the child operator's rows verbatim, so the tablet that enters the storage layer can have any order. Several places assume the opposite:TsFileProcessorupdates the tsfile resource boundaries from the first/last row of each rangefalseDataRegion.split()InsertTabletNode.collectSplitRanges()InsertTabletNode.checkTTLInternal(), used byDataRegionInsertTabletNode.composeLastTimeValuePair(), used for the last cachetimestamps[0] .. timestamps[rowCount - 1]is the time rangePipeInsertNodeTabletInsertionEvent.mayEventTimeOverlappedWithTimeRange()2. Write side: the sequence flag is latched
DataRegion.split()classified the rows of a tablet by latching the sequence flag:Once
isSequencebecametrue, every remaining row of the device was classified as sequence regardless of its timestamp. For the tablet[100, 15]of a device whose last flushed time is39, the row15was written into a new sequence TsFile, producing an overlapping sequence file.3. Read side: overlapping sequence pages are merged too late
SeriesScanUtilexpands the pages of every overlapping sequence chunk intoseqPageReaders, an append-only list, and both the overlap check (firstPageOverlapped) and the page selection (getFirstPageReaderFromCachedReaders) only look at the head of that list. Once files overlap, the pages of the later file are appended after the pages of the earlier one, so a page that starts before the head is not examined until the earlier file is exhausted; by then its rows have already been returned.With
P1[10,20] P2[21,30] P3[31,39]from the first file andP4[15,100]from the second:P1[10,20]P2[21,30]20 >= 21falseP2[21,30]P3[31,39]30 >= 31falseP3[31,39]P4[15,100]39 >= 15trueP3andP4mergedSo
P4is merged, but only afterP1andP2have already been emitted: rows come back twice and out of time order.Fix
Engine ingress: sort the tablet before it is written
InsertTabletStatementGenerator.constructInsertTabletStatement()sorts the rows stably by time, permuting the value columns and the null bitmaps along with the timestamps. A tablet that is already sorted — the normal case — returns after one comparison per row and is not copied.This restores every assumption in the table above at once, instead of patching six consumers independently.
Write side: no longer latch the sequence flag
Track whether the device's rows are still in ascending time order:
time < previousTimecomparison per row; no additionallastFlushTimelookups.For
[100, 15]the split becomes seq[[0,1)]+ unseq[[1,2)]: the older row goes to the unsequence space and no overlapping sequence file is created.Alternatives considered and not taken: sorting the tablet before the insert (needs remapping
resultsand theprocessedRowsbitset) or sorting only inTableIntoOperator(would leave the sessioninsertTabletAPI unprotected). Fixing it in the storage layer covers every ingress.Read side: keep the sequence page readers in scan order
seqPageReadersis now kept ordered in scan order when a page is unpacked (ascending: by start time, descending: by end time); the unsequence side already uses an ordered priority queue.For non-overlapping files every insert lands at the tail, so the iteration order is unchanged and the only cost is one comparison per page. This keeps query results correct for overlapping sequence files that already exist on disk.
Testing
InsertTabletStatementGeneratorTest(new):[100, 15]come out as[15, 100]with the values following their rows;Write side —
DataRegionTest#testOutOfOrderTabletKeepsSequenceSpaceNonOverlapped:10..39for a device and close the file (last flushed time =39);[100, 15]for the same device;RepairDataFileScanUtil.checkTimePartitionHasOverlap) and that15landed in the unsequence space.It fails on the unpatched code (
unseqFilesexpected1, was0) and passes with the patch.Read side —
OverlappingSeqChunkScanTestwrites overlapping sequence files directly and covers:f1 [10,19][20,29][30,39],f2 [25,27]);f1 [10,20][21,30][31,39],f2 [15 ... 100]);f1 [10,19][20,29],f2 [12,13,14]);each asserted to be strictly ordered and duplicate-free, in both scan directions where applicable. All three fail on the unpatched code — case 1 returns 33 points instead of 30, with
25 26 27repeated after29.IoTDBInsertQueryIT#testInsertIntoQueryWithOutOfOrderTabletDoesNotOverlapSequenceFilesreproduces the situation end-to-end through SQL and asserts the rows come back in time order.TreeIntoOperatorTest,SeriesScanUtilCancellationTest,SeriesAggregationScanOperatorTestandAlignedSeriesScanOperatorTestalso pass.Out of scope
insertTabletAPI. It documents that the batch must be sorted by the client (InsertTabletNode#times); a client that violates that contract can still reach the storage layer with an unsorted tablet.🤖 Generated with Claude Code