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.12% +0.14%
- Complexity 604 621 +17
============================================
Files 5572 5572
Lines 409698 410324 +626
Branches 54009 54140 +131
============================================
+ Hits 196558 197461 +903
+ Misses 213140 212863 -277 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
35286ac to
077029a
Compare
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?
|
I would like to keep this PR scoped to the The invariant violation originates because the internal I do not think this PR should add defensive behavior to the general storage write path or to the query read path. Please remove the Once |
10f4668 to
7f12bc2
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.
7f12bc2 to
0ed3860
Compare
|




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
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.
DataRegion.split()latches the sequence flagOnce
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.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.The two changes cover different ingresses: the sort covers the engine's own
INSERT INTO ... SELECT/SELECT INTOpath, while the per-row re-check keeps the classification correct for any tablet that reachesDataRegion.insertTablet(), including the sessioninsertTabletAPI.Testing
InsertTabletStatementGeneratorTest(new, 8 cases):[100, 15]come out as[15, 100]with the values following their rows;reset();DataRegionTest:testOutOfOrderTabletKeepsSequenceSpaceNonOverlapped— insert10..39, close the file (lastFlushTime = 39), insert the tablet[100, 15], then assert the sequence space has no overlap (RepairDataFileScanUtil.checkTimePartitionHasOverlap) and that15landed in the unsequence space;testOutOfOrderTabletWithMultipleInversionsKeepsSequenceSpaceNonOverlapped— same with several descending steps in one tablet.They fail on the unpatched code (
unseqFilesexpected1, was0) and pass with the patch.IoTDBInsertQueryIT#testInsertIntoQueryWithOutOfOrderTabletDoesNotOverlapSequenceFilesreproduces the situation end-to-end through SQL and asserts the rows come back in time order, in both scan directions.TreeIntoOperatorTestalso passes.Out of scope
insertTabletAPI. It documents that the batch must be sorted by the client (InsertTabletNode#times); the per-row re-check keeps the sequence/unsequence classification right for unsorted tablets, but the other assumptions in the table above still hold only for sorted input.START REPAIR DATAmoves an overlapping sequence file to the unsequence space.🤖 Generated with Claude Code