Skip to content

Add LTTB downsampling as a built-in Table Model table function - #18725

Open
DaZuiZui wants to merge 13 commits into
apache:masterfrom
DaZuiZui:feat/lttb-table-function
Open

DaZuiZui wants to merge 13 commits into
apache:masterfrom
DaZuiZui:feat/lttb-table-function

Conversation

@DaZuiZui

@DaZuiZui DaZuiZui commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Description

Closes #18532

Adds LTTB (Largest-Triangle-Three-Buckets) as a built-in Table Model table function for visualization-oriented downsampling, following the conventions of the existing M4 table function.

Function definition

-- Target-count mode
SELECT * FROM LTTB(DATA => t PARTITION BY device ORDER BY time, TIMECOL => 'time', N => 500);

-- Count-window mode
SELECT * FROM LTTB(DATA => t PARTITION BY device ORDER BY time, TIMECOL => 'time', SIZE => 100, SLIDE => 100);

-- Time-window mode
SELECT * FROM LTTB(DATA => t PARTITION BY device ORDER BY time, TIMECOL => 'time',
                   SIZE => 1m, SLIDE => 1m, ORIGIN => 2026-01-01T00:00:00.000+00:00);
  • DATA (set semantics) / TIMECOL / SIZE / SLIDE / ORIGIN reuse the M4 argument semantics and window construction (half-open [start, start+SIZE) windows, ORIGIN alignment, count windows for integer SIZE).
  • N selects target-count mode: the whole partition is buffered and standard LTTB reduces each participant column to exactly N points (all points when M <= N), always preserving the first and last eligible points. Buckets follow [floor(i*(M-2)/(N-2))+1, floor((i+1)*(M-2)/(N-2))+1); the final bucket uses the last point as vertex C. Output uses window_index = 0.
  • SIZE selects window/bucket mode: each window is one LTTB bucket. Per participant column the anchor is chained across windows (the first eligible point seeds it), vertex C is the average point of the next window that carries data for the column (or the window's own last eligible point when there is none), and empty buckets leave the anchor untouched.
  • Participant columns are inferred automatically (INT32/INT64/FLOAT/DOUBLE; other types are rejected with the shared "not allowed columns" message). NULL/non-finite values are ignored per column; columns with no eligible points emit NULL. Area ties resolve to the earliest timestamp (stable input order). Integral columns keep exact long values in the output.
  • Output schema follows M4: window_start, window_end | window_index, partition columns, then <col>_time, <col> per participant column, aligned by output position and padded with NULL.

Validation

Rejected at analysis time: neither/both of N and SIZE; N < 3; N given as a duration; N with SLIDE/ORIGIN; SLIDE/ORIGIN without SIZE; ORIGIN in count-window mode; SLIDE mode differing from SIZE; descending ORDER BY; non-TIMESTAMP TIMECOL; unsupported participant types. All new messages are i18n constants in both en and zh CommonMessages.

Execution and distribution

  • TableDistributedPlanGenerator.canSplitTableFunctionProcessor now excludes LTTB (like FFT), so the whole partition is gathered and ordered (MergeSort over exchanges) before the function runs — fragment-local LTTB samples are not mergeable.
  • Both modes share a 64 MiB per-processor budget for point buffers across all columns; target-count selection indexes and array-growth peaks are reserved before allocation. The per-column MAX_BUFFERED_POINTS cap also remains in effect. Pending windows waiting for a non-empty successor per column share the same budget.
  • Triangle calculations subtract timestamps before converting to double, preserving nanosecond differences at modern epoch timestamps. Window lookahead links each column to its next non-empty closed bucket and emits completed windows in order.

Alternatives considered

  • Fully independent windows in SIZE mode (no chained anchor) would simplify overlapping windows but diverges from the issue's "previously selected point / next-bucket average" definition; chained anchors were chosen.
  • Reusing M4TableFunction's ValueOperator was rejected because LTTB requires numeric values for triangle areas.

This PR has:

  • been self-reviewed.
  • added Javadocs for most classes and all non-trivial methods.
  • added comments explaining the "why" and the intent of the code wherever would not be obvious for an unfamiliar reader.
  • added unit and planner tests (LTTBTableFunctionTest, LTTBWindowMemoryTest, LTTBRegressionTest, TableFunctionTest). The 18 LTTB unit tests pass in both en and zh; the 24 planner tests pass in en. Memory regression suites also pass individually with a 128 MiB heap.
  • added integration tests (IoTDBLTTBTableFunctionIT, all four tests pass with both -PTableSimpleIT and -PTableClusterIT).

Key changed/added classes (or packages if there are too many classes) in this PR
  • org.apache.iotdb.commons.udf.builtin.relational.tvf.LTTBTableFunction (new)
  • org.apache.iotdb.commons.queryengine.plan.relational.function.TableBuiltinTableFunction
  • org.apache.iotdb.db.queryengine.plan.relational.analyzer.StatementAnalyzer (tryAppendLTTBModeArgument)
  • org.apache.iotdb.db.queryengine.plan.relational.planner.RelationPlanner
  • org.apache.iotdb.db.queryengine.plan.relational.planner.distribute.TableDistributedPlanGenerator
  • org.apache.iotdb.commons.i18n.CommonMessages (en/zh)

🤖 Generated with Claude Code

Recompute a bucket with exact arithmetic when floating-point area or
lookahead calculations overflow, preserving finite peaks and stable ties.
Keep the existing fast path for ordinary input values.

Check count-window ends only when their start is reached, reject an
unrepresentable end, and stop advancing before the next start overflows.
This prevents stalled processing and spurious windows for large sizes.

Add six numeric boundary regressions covering all three sampling modes,
lookahead sums, opposite-sign differences, area ties, and count bounds.

@JackieTien97 JackieTien97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two reproduced behaviors need to be addressed against the clarified definition in #18532: duplicate input timestamps must produce semantic error 701, and non-NULL floating-point values must not be silently discarded in target-count mode. Both issues reproduce in TableSimpleIT and TableClusterIT. The existing 33 LTTB unit tests, 24 planner tests, and five LTTB integration tests pass; the additional regression cases expose the gaps described inline.

Comment on lines +819 to +821
hasRows = true;
capturePartitionValues(input);
collect(input, input.getLong(0), buffers);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reject duplicate input timestamps with semantic error 701

This input path, and AbstractWindowLTTBDataProcessor.process, consume records without checking whether the timestamp has already occurred in the partition. With a single partition containing (time, value) = (1, 1), (1, 9), (2, 2), (3, 3), all three invocations (N => 3, SIZE => 2, and SIZE => 2ms) succeed instead of reporting the required 701 error. In the standalone and cluster reproductions, the count-window query even selected different values at timestamp 1.

Please validate timestamp uniqueness at the partition input boundary, before per-column NULL filtering and window expansion, and throw a semantic exception exposed to the client as 701. Preserve this check across input blocks and after gathering distributed input. Equal timestamps in different partitions must remain valid, and a row appearing in multiple overlapping windows must not be mistaken for a duplicate input row. This follows the clarified requirement in #18532 (comment).

Comment on lines +581 to +585
case DOUBLE:
double doubleValue = record.getDouble(inputIndex);
if (Double.isFinite(doubleValue)) {
buffer.addFloating(time, doubleValue);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not silently discard non-NULL values in short series

The finite-value check here, and the equivalent FLOAT check, drops NaN and positive/negative infinity before counting eligible points. For three non-NULL input points (0, NaN), (1, Infinity), (2, Infinity) with N => 3, the query returns a single (window_index=0, value_time=NULL, value=NULL) row. This reproduces in both standalone and cluster execution with a table subquery computing s1 / 0.0; the input subquery itself returns all three points.

The current definition only excludes NULL participant values and requires returning all eligible points when their count does not exceed N. Please preserve those values for this short-series case, or explicitly define non-finite values as unsupported and report an error. Silently turning populated input into an all-NULL result changes the data and violates the current return-all contract.

@DaZuiZui

Copy link
Copy Markdown
Contributor Author

@JackieTien97 thank

DaZuiZui and others added 3 commits October 13, 2026 00:33
…alues

Address the review findings on the LTTB table function:

- Duplicate timestamps within one input partition are now rejected with
  SEMANTIC_ERROR (701) instead of silently selecting one of the rows. The
  check runs at the partition input boundary, before per-column NULL
  filtering and before window expansion, and carries the last seen
  timestamp across input blocks. Equal timestamps in different partitions
  stay valid, and a row shared by overlapping windows is still a single
  input row.
- Non-NULL participant values are no longer dropped when they are NaN or
  +/-Infinity. The definition only excludes NULL participants, so
  discarding non-finite values silently turned populated input into an
  all-NULL result for short series.

Add unit coverage for the uniqueness checker (including the block
boundary) and for keeping non-finite points, plus integration coverage for
all three invocations (N, SIZE count window, SIZE time window), the
different-partition case, and the non-finite short series.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eat/lttb-table-function

Bring in the four LTTB fix commits from the remote branch and re-apply the
review fixes on top of them:

- Duplicate input timestamps within one partition are rejected with
  SEMANTIC_ERROR (701) at the partition input boundary, before per-column
  NULL filtering and window expansion, carrying the last timestamp across
  input blocks. Equal timestamps in different partitions stay valid.
- Non-NULL participant values that are NaN or +/-Infinity are kept instead
  of being discarded, so a short series no longer collapses to all NULL.

The memory-regression fixtures in LTTBWindowMemoryTest and
LTTBRegressionTest fed the same timestamp for every buffered row; they now
advance the input time per row, since repeated timestamps are invalid
input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 701 integration test built its duplicated partition with a UNION ALL
whose second branch was a literal row. That plan carries a ValuesNode with
inlined rows, and dispatching such a fragment over the cluster fails in
ValuesNode deserialization with BufferUnderflowException, so the test
reported 301 instead of 701 under TableClusterIT.

Both branches now scan the real metrics table, matching the UNION ALL shape
already used by other table-model ITs. The partition still repeats every
d1 row, so the duplicate-timestamp check is exercised identically, and the
expected timestamp is now 0.

Verified with TableSimpleIT and TableClusterIT: 8 tests pass in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@JackieTien97 JackieTien97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing the duplicate-timestamp (P1) and non-finite-value (P2) findings in the 10-12 commits. Four remaining items need to be resolved before merge. See inline comments for specifics.

private static BigDecimal exactValue(double[] values, long[] integralValues, int index) {
// The constructor preserves the actual binary floating-point value, including tiny deltas.
return integralValues == null
? new BigDecimal(values[index])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 follow-up] new BigDecimal(values[index]) throws NumberFormatException when the value is NaN or ±Infinity.

The fast path (argMaxArea, around line 453) returns NOT_SELECTED when the computed area is non-finite, which causes the caller to invoke argMaxAreaExactly as a fallback. The Javadoc for that method still says "Overflow fallback for finite input values", and new BigDecimal(Double.NaN) throws NumberFormatException unconditionally — it does not produce a numeric result.

The existing integration test only covers the short-series path (M <= N) where all points are returned as-is and no area is computed. A series longer than N where any bucket contains a NaN or Infinity participant will crash with an unchecked NumberFormatException at this line.

Options:

  1. Guard exactValue: check Double.isFinite(values[index]) and treat non-finite candidates as zero area (i.e. always pick the first point in the bucket when all candidates are non-finite), or skip them.
  2. Reject non-finite participant values at the point they enter the buffer (in collect()) with a clear 701 error.

Either way, please add a regression test for the case where N < M and at least one candidate in a bucket is NaN or Infinity.

public static final int MAX_BUFFERED_POINTS = 1 << 22;

/** Shared budget for point buffers and selection state across all columns of one processor. */
static final long MAX_BUFFER_BYTES = 64L * 1024 * 1024;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[config] MAX_BUFFER_BYTES is a compile-time constant. Please expose it as a hot-modifiable configuration parameter (following the same pattern used by other per-query memory limits in IoTDB) so operators can tune it for workloads with large partitions or many overlapping windows without restarting the node.

In addition, when a query plan includes an LTTB node, this budget should be added to the query's estimated execution memory. Currently TableFunctionOperator.calculateMaxPeekMemory() and calculateRetainedSizeAfterCallingNext() do not account for LTTB's internal buffers, so the engine cannot reclaim or back-pressure against them under memory pressure.

// LTTB is not mergeable: fragment-local samples cannot be combined into a globally correct
// result, so the whole partition must be gathered and ordered before the function runs.
return !TableBuiltinTableFunction.FFT.getFunctionName().equalsIgnoreCase(node.getName())
&& !TableBuiltinTableFunction.LTTB.getFunctionName().equalsIgnoreCase(node.getName());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[planner] Please remove the LTTB name from this exclusion list.

ParallelizeGrouping + GroupNode already guarantees that, when PARTITION BY covers the device-level partition keys, each fragment receives complete partitions before any set-semantic TVF runs. That is the only property LTTB needs — it is identical to what M4 relies on to remain splittable without any exclusion here.

Excluding LTTB by name serialises all partitions through the coordinator unnecessarily and will degrade performance on large tables with many partitions.

More broadly, the non-mergeability property should live in the planner rather than in a growing name-based list. Consider expressing it as an attribute on the TVF registration or on TableFunctionProcessorNode so future non-mergeable TVFs do not require a new entry here.

+ " the same partition.";
public static final String EXCEPTION_UNSUPPORTED_LTTB_VALUE_TYPE_ARG_3E60F6FE =
"Unsupported LTTB value type: %s";
public static final String EXCEPTION_LTTB_COUNT_WINDOW_BOUNDARIES_EXCEED_THE_INT64_RANGE =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[i18n] This constant name is missing its hash suffix. Following the project naming rule (<PREFIX>_<NORMALIZED>_<HASH>), it should be:

EXCEPTION_LTTB_COUNT_WINDOW_BOUNDARIES_EXCEED_THE_INT64_RANGE_5BC980CA

Please apply the same rename to the zh file to keep the two files in parity.

- Guard the exact-area fallback against non-finite participants: a NaN or
  Infinity candidate is scored as the smallest area (earliest point wins)
  instead of reaching `new BigDecimal(Double.NaN)`, which threw an unchecked
  NumberFormatException whenever N < M and a bucket held such a value. The
  fallback still runs for a non-finite lookahead average over representable
  points, since it rebuilds the sum from the original points.
- Replace the compile-time MAX_BUFFER_BYTES with the hot-reloadable
  `lttb_buffer_size_in_bytes` parameter, resolved through
  loadFixedSizeLimitForQuery so the default scales with the node's operator
  memory (as sort_buffer_size_in_bytes does) rather than charging a fixed
  64 MB to every LTTB fragment, which tripped the fragment admission check
  on small-memory nodes.
- Report the LTTB buffers to the engine: a new
  TableFunctionProcessorProvider#getEstimatedMaxBufferedBytes hook feeds
  TableFunctionOperator's peek and retained estimates, matching how
  AbstractSortOperator accounts for its sort buffer.
- Drop LTTB from the planner's name-based split exclusion. The partitioned
  GroupNode the function requires already guarantees whole partitions per
  fragment, so fragment-local samples are valid standard LTTB results.
- Give the count-window INT64 overflow constant its hash suffix.

Add unit coverage for non-finite sampling (N < M, all-non-finite bucket) and
integration coverage for the new configuration parameter.
…ary rules

Document what the clarification fixes and pin it with tests:

- SIZE defines a bucket, not the input range of a separate LTTB invocation.
  One anchor chain per partition and participant column: A is the point the
  preceding bucket selected, B becomes the anchor of the following bucket, and
  overlapping buckets (SLIDE < SIZE) do not restart the chain. State stays
  independent between partitions and between columns.
- Bucket mode emits one row per window that carries input, at most one point
  per column. A column with no eligible point emits NULL, and an all-NULL
  window still emits a single all-NULL row.
- Bucket mode does not preserve the first and last eligible points: an endpoint
  used as A or as C is a triangle vertex, not a selected point, so it is
  returned only when it also wins its bucket. A bucket with two eligible points
  contributes one row, the tie resolving to the earlier point. First/last
  preservation and return-all-points remain target-count behaviour.

Tests: the anchor chain across overlapping buckets (with the count-window and
time-window expected indexes), two eligible points in one bucket emitting one
row, and an all-NULL column still emitting one NULL row per window. The
count-window case selects t=1 in bucket [1,4), which is only reachable with a
chained anchor.

@JackieTien97 JackieTien97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick update. I verified at 213393e:

  • Duplicate timestamps now fail with 701 in N, count-window and time-window mode, including duplicates whose values are NULL. Rows shared by overlapping windows are not flagged.
  • The new BigDecimal(NaN) crash is fixed. 6,000 randomized cases with NaN, ±Infinity and ±1.7e308 raised no exceptions.
  • The i18n constant name is fixed, and the -P with-zh-locale build compiles.
  • IoTDBLTTBTableFunctionIT passes with -PTableSimpleIT and -PTableClusterIT, and IoTDBLoadConfigurationTableIT passes with -PTableSimpleIT.

The remaining items are inline:

  1. [planner] My earlier comment about the per-fragment split was wrong. Please restore the LTTB exclusion, and merge-sort the input when the function is not split; otherwise partitions are mixed. We'll fix the split rule itself in a separate PR.
  2. [test] TableFunctionTest.testLTTBTargetCountMode fails at this head. Restoring the exclusion fixes it.
  3. [api] Please keep the buffer estimate out of the public udf-api.
  4. [memory] After a hot reload, the reserved budget can differ from the budget the processors enforce.
  5. [docs] The defaults and comments of lttb_buffer_size_in_bytes don't match the code.
  6. [non-finite] The selection degenerates when A or C is non-finite.
  7. [tests] Some integration-test gaps remain.
  8. [semantics] SIZE mode must return the first and last points, and a window without eligible points must not emit a row.

Please also update the PR description. Several statements still describe the first revision:

  • SIZE mode. It still says "the first eligible point seeds it". Please describe the rules from the [semantics] comment:
    • windows are buckets;
    • the first and last eligible points are buckets of their own and are always returned;
    • overlapping windows continue the anchor chain;
    • C comes from the next bucket that has data;
    • windows without eligible points emit no row.
  • Non-finite values. "NULL/non-finite values are ignored per column" is outdated: non-finite values are now eligible and returned.
  • Planner. Once the exclusion is restored, the MergeSort statement is true again. Please also mention the merge fix in visitTableFunctionProcessor.
  • Memory. "Both modes share a 64 MiB per-processor budget" is outdated. The budget is now lttb_buffer_size_in_bytes: hot reload, default min(32 MiB, …), charged to the operator's memory estimate.
  • Duplicates. Rejecting duplicate timestamps with 701 (issue point 4) is not mentioned.
  • Test summary. "18 LTTB unit tests", "24 planner tests pass" and "all four tests pass" are outdated: the IT now has 8 tests. Please update the counts after this round.
  • Key changed classes. Please add CommonConfig, IoTDBDescriptor, iotdb-system.properties.template and TableFunctionOperator.

I'll approve the CI run once these are addressed.

Comment on lines +2902 to +2905
// LTTB needs whole partitions, which the partitioned GroupNode above this node already
// guarantees: every fragment receives complete partitions and the ordering is preserved, so a
// fragment-local sample is a valid standard LTTB result for that partition and no exclusion is
// needed. FFT is excluded because its transform over the whole partition is not splittable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[planner] Please restore the LTTB exclusion and merge-sort the input when the function is not split

My earlier comment (r4253809521) was wrong. A partitioned GroupNode groups and orders rows within each branch. It does not make a partition complete within one branch. ParallelizeGrouping keeps the GroupNode whenever its partition keys are a prefix of the child's keys (checkPrefixMatch), and canSplitTableFunctionProcessor only checks for a partitioned GroupNode. The comment added here states the same wrong guarantee.

The split gives wrong results at 213393e. I ran an end-to-end check on 1C1D with four data regions (CUSTOM policy, 4 data region groups per database). The setup had two partitions, tag3 = 'a' and 'b', with 8 devices each and interleaved timestamps, and used N => 4. Results were compared with a reference LTTB.

SELECT * FROM LTTB(DATA => (SELECT time, tag3, s1, row_number() OVER (PARTITION BY tag3, dev ORDER BY time) AS rn FROM t)
                   PARTITION BY tag3 ORDER BY time, TIMECOL => 'time', N => 4)
  • One LTTB runs per region, so every partition returns 16 rows instead of 4.
  • A SESSION input partitioned by (tag3, dev) gives the same result.
  • When two devices of one partition share timestamps, no 701 is raised.
  • A direct scan with PARTITION BY tag3 is correct.

Restoring the exclusion alone is not enough.

  • The non-split branch of visitTableFunctionProcessor (lines 2886–2888) merges the children using the ordering that nodeOrderingMap holds for the first child.
  • visitGroup does not register the per-branch GroupNodes it creates (line 519). The lookup therefore returns null, and the children are merged by a CollectNode instead of a MergeSort.
  • PartitionRecognizer.findNextDifferentRowIndex assumes grouped input: a block whose last row has the current partition key is taken whole into the current partition.

With the exclusion restored, the query above runs a single LTTB, but over mixed partitions. I checked this with N => 1000, which returns every row a partition receives:

  • the LTTB for partition a received 8 of its 32 rows;
  • the LTTB for b received 56 rows, 24 of them from partition a, and its timestamps went backwards 6 times;
  • shared timestamps still raise no 701.

FFT, which is already excluded, is planned the same way.

Please, in this PR:

  1. Restore the exclusion, and replace the comment at lines 2902–2905.

  2. Merge-sort the children in the non-split branch using the GroupNode's ordering. This is valid because every child is either a per-branch GroupNode or a node whose ordering starts with that ordering:

    } else if (!canSplitPushDown) {
      OrderingScheme childOrdering =
          node.getChild() instanceof GroupNode
              ? ((GroupNode) node.getChild()).getOrderingScheme()
              : nodeOrderingMap.get(childrenNodes.get(0).getPlanNodeId());
      node.setChild(mergeChildrenViaCollectOrMergeSort(childOrdering, childrenNodes));

    With both changes, these inputs are planned as TableFunctionProcessor -> MergeSort. On 1C1D and 1C3D, the direct scan, the window-function input and the SESSION input then all match the reference, and shared timestamps raise 701. All 24 tests in TableFunctionTest pass.

  3. Add an IT for this setup:

    • call setDataRegionGroupExtensionPolicy("CUSTOM") and setDefaultDataRegionGroupNumPerDatabase(4) before initClusterEnvironment();
    • put several devices in each partition;
    • compare the results for the window-function and SESSION inputs with the direct-scan result;
    • add a partition whose devices share timestamps; it must fail with 701.

The split rule itself is a separate planner issue. It also affects M4: over the window-function input above, M4 with a single count window returns 15 rows per partition instead of at most 4, with or without these changes. visitWindowFunction and visitPatternRecognition use the same GroupNode test. We'll fix the split rule in a separate PR, and the LTTB exclusion can be removed after that.

Comment on lines +914 to +919
// LTTB is not mergeable: the whole partition is gathered and ordered before the function runs
assertPlan(
planTester.getFragmentPlan(0),
output(
tableFunctionProcessor(
tableFunctionMatcher, mergeSort(exchange(), exchange(), exchange()))));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test] This assertion fails at 213393e

TableFunctionTest.testLTTBTargetCountMode:915 Plan does not match

5cdb336 removed the LTTB exclusion. Since then, fragment 0 is Output -> Collect over the exchanges, with one LTTB per fragment. Once the exclusion is restored (see the planner comment), the plan is Output -> TableFunctionProcessor -> MergeSort(Exchange x3) again, and this test passes unchanged.

Please also add a planner test for the window-function input from the planner comment. Its fragment 0 should be Output -> TableFunctionProcessor -> MergeSort over the exchanges. Without the merge fix, the merge is a Collect.

Comment on lines +47 to +57
/**
* The bytes the processors of this provider may hold in their own buffers while a partition is
* being processed, above what {@code process} returns. The engine adds this to the estimated
* memory of the {@linkplain TableFunctionDataProcessor} operator, so a table function that
* accumulates input (rather than emitting a result per record) is visible to memory management
* and can be back-pressured. The default is {@code 0} for table functions that keep no state
* across calls.
*/
default long getEstimatedMaxBufferedBytes() {
return 0L;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[api] Please don't add this to the public UDF API

TableFunctionProcessorProvider is part of udf-api, which user-defined table functions implement. A new method here is a public API change that we would have to support for every UDF from now on, and it exposes engine memory accounting to user code. Please revert this file.

The engine can get the value internally instead. TableOperatorGenerator.visitTableFunctionProcessor (calc-commons, lines 1756–1758) already resolves the TableFunction and calls getProcessorProvider(handle). From there it can:

  1. get the budget from the built-in LTTB provider, for example through an internal interface in node-commons or an instanceof check (user-defined functions get 0);
  2. pass that value to the TableFunctionOperator constructor.

The value it passes should be the same one the processors enforce; see the TableFunctionOperator comment.

new PartitionRecognizer(
partitionChannels, requiredChannels, passThroughChannels, inputDataTypes);
this.isDeclaredAsPassThrough = isDeclaredAsPassThrough;
this.estimatedMaxBufferedBytes = processorProvider.getEstimatedMaxBufferedBytes();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[memory] The reserved budget can differ from the budget the processors enforce

The estimate is read once, here, when the operator is built. The enforced limit, however, is read again for every partition:

  • this operator creates a new processor per partition (line 181);
  • each processor calls new BufferMemoryBudget(maxBufferBytes()) (LTTBTableFunction.java lines 938 and 1076);
  • maxBufferBytes() reads the live lttb_buffer_size_in_bytes.

So a hot reload during a running query changes the limit for every partition that starts afterwards, while the reservation keeps the old value.

A probe shows the gap. With the value at 1 MiB, the estimate is 1,048,576. After setLttbBufferSize(4 MiB), which is what a hot reload does, the processor created for the next partition only fails at row 131,072, with "LTTB buffers exceed the per-processor memory limit of 4194304 bytes". That is 4 MiB buffered against a 1 MiB reservation.

Please resolve the budget once, for example when the LTTB provider is created, and use that same value for every processor of the provider and for this estimate. The two are then equal by construction.

Comment on lines +125 to +131
/**
* Shared budget for point buffers and selection state across all columns of one processor. It is
* the default of {@code lttb_buffer_size_in_bytes}; operators can tune the live value with that
* hot-reloadable configuration parameter for workloads with large partitions or many overlapping
* windows.
*/
public static final long DEFAULT_MAX_BUFFER_BYTES = 64L * 1024 * 1024;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[docs] The defaults and comments of lttb_buffer_size_in_bytes don't match the code

The 64 MiB default. This Javadoc calls 64 MiB "the default of lttb_buffer_size_in_bytes", but the effective default is min(32 MiB, operator memory / query_thread_count / 2). IoTDBDescriptor.loadFixedSizeLimitForQuery sets that value, maps values ≤ 0 to it, and iotdb-system.properties.template documents it. 64 MiB only shows up in two places:

  • the initial value in CommonConfig (line 573). By contrast, sortBufferSize, which the template says this parameter matches, starts at 32 MiB;
  • the ≤ 0 fallback in maxBufferBytes(). A DataNode never reaches it after loading its properties.

Please keep a single default, and drop or align the extra constant. LTTBRegressionTest and LTTBWindowMemoryTest reference the constant too.

The maxBufferBytes() Javadoc (lines 151–157). It says "Processors read this once at construction, so a reload applies to queries that start afterwards". That isn't what happens. A processor is constructed per partition, so a reload reaches the next partition of a query that is already running; see the TableFunctionOperator comment. Please describe the actual rule once the value is resolved per provider.

TableFunctionOperator. The comments at lines 298 and 312 should describe the new mechanism.

Optional: the error message. "LTTB buffers exceed the per-processor memory limit of %d bytes." could name lttb_buffer_size_in_bytes, so users know what to tune. That needs a new constant name and hash in both en and zh.

Comment on lines +511 to +515
// The exact fallback rebuilds the lookahead sum from the original points, so a non-finite
// average over representable points is still recoverable; only the anchor origin has to be
// representable, because it is converted to BigDecimal directly. A non-finite anchor value can
// only arise from non-finite input, which no arithmetic can repair.
boolean exactIsPossible = Double.isFinite(anchorValue);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-finite] The selection degenerates when a triangle vertex is non-finite

Non-finite values are now eligible points (lines 93–95), so they can become the anchor A or enter the lookahead C. When either is non-finite, every area in the bucket is non-finite and boundedArea maps each one to −1. exactIsPossible is false, or the exact path has no finite lookahead point, so pick stays at start. The bucket then returns its earliest point regardless of the data.

The examples below use one DOUBLE column with t = 0..5, at 213393e. The last column shows the result when the non-finite values are replaced by finite ones.

input args result with finite values instead
NaN, 1, 2, 9, 0, 0 N => 3 t0:NaN, t1:1, t5:0 (t0 = 0) t0:0, t3:9, t5:0
NaN, 1, 9, 2, 0, 0 SIZE => 3 0|t0:NaN, 1|t3:2 (t0 = 0) 0|t2:9, 1|t3:2
0, NaN, NaN, 1, 9, 0 N => 4 t0:0, t1:NaN, t3:1, t5:0 (5, 5) t0:0, t1:5, t4:9, t5:0
0, 1, 9, NaN, NaN, 0 N => 4 t0:0, t1:1, t3:NaN, t5:0 (5, 5) t0:0, t2:9, t4:5, t5:0

These rows show three separate cases:

  1. A non-finite first point as A (rows 1–2). In row 1 the peak t3:9 is lost. In row 2, window 0 returns the NaN anchor itself; a time window behaves the same. ±Infinity behaves like NaN: Infinity, 1, 2, 9, 0, 0 with N => 3 gives t0:Infinity, t1:1, t5:0.
  2. A non-finite selected point as the next A (row 3). An all-NaN bucket can only return a NaN point. That point becomes the anchor of the next bucket, which then returns t3:1 instead of the peak t4:9.
  3. No finite point in the next bucket, so C is undefined (row 4). The current bucket returns t1:1 instead of t2:9.

The comment at line 514 treats this as unavoidable ("no arithmetic can repair"). However, the geometry can avoid non-finite vertices without changing which points are eligible for output. For example:

  • A is the most recent finite selected point, or the first finite eligible point;
  • C is the average of the finite points of the next bucket that has one, using the same skip rule as for buckets without eligible points; if there is none, C is the last finite eligible point;
  • a bucket with no finite candidate still returns its earliest point, but does not move A.

With these rules, all four cases above select the peak. Whatever rule you choose, please state it in the class Javadoc and add expected-result tests for the three cases, in both N and SIZE mode.

The endpoint rule in the [semantics] comment does not change this. A non-finite first or last point is still returned, but it should not become A or C.

import static org.junit.Assert.fail;

@RunWith(IoTDBTestRunner.class)
@Category({TableLocalStandaloneIT.class, TableClusterIT.class})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[tests] Remaining integration-test gaps

I should have included these in my previous review. At 213393e the IT still lacks the following:

  1. SLIDE. No test checks results for SLIDE < SIZE or SLIDE > SIZE, in either time or count windows. SLIDE < SIZE gives overlapping windows that continue the anchor chain (issue point 1). SLIDE only appears in testInvalidParameterCombinations, and testOverlappingWindowsContinueTheAnchorChain only covers the processor.
  2. ORIGIN. Only the epoch is used (lines 126 and 150). Please add an ORIGIN that is not aligned with the data, so that the window boundaries actually shift.
  3. TIMECOL. No LTTB test at any level (IT, planner or UT) covers an invalid TIMECOL, such as a non-TIMESTAMP column or a column that does not exist. The PR description lists a non-TIMESTAMP TIMECOL as rejected at analysis time.
  4. Duplicate timestamps (issue point 4). Duplicates across input blocks are only tested at checker level (testTimestampUniquenessSpansInputBlocks), and the IT duplicates the rows of a single device with UNION ALL. No test covers equal timestamps that come from different devices of one partition, e.g. PARTITION BY a tag that two devices share. Please add this case with the multi-region setup from the planner comment, for a direct scan and for the window-function and SESSION inputs. It must report 701 with both -PTableSimpleIT and -PTableClusterIT.

Comment on lines +83 to +91
* <p>Bucket mode emits one row per window that carries input, and at most one selected point per
* participant column in a row; a column with no eligible point in a window emits {@code NULL} in
* its place, and a window in which every participant column is {@code NULL} still emits a single
* all-{@code NULL} row (padding by output position). Bucket mode does <em>not</em> preserve the
* first and last eligible points: an endpoint used as {@code A} or as {@code C} is a triangle
* vertex, not a selected point, so it appears in the output only when it also wins its bucket. A
* bucket holding just one or two eligible points therefore still contributes a single row per
* column: with two points the areas are equal and the tie resolves to the earlier one. First/last
* preservation and the return-all-points case apply to target-count mode only.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[semantics] SIZE mode must return the first and last points, and a window without eligible points must not emit a row

Thanks for making the boundary rules explicit. Two of them need to change.

1. Return the first and last eligible points, as the original LTTB does.

At 213393e the first eligible point is only the initial A, and the last one is only the final C. As a triangle vertex, a point has zero area, so neither endpoint is returned unless it is alone in its window. The last point is the most recent reading, so a chart built with SIZE => 1m never shows it.

Real SQL results at 213393e (t = 0, 1, 2, ...):

input SIZE 213393e N mode
0,1,0,1,0,9 3 w0 t2:0; w1 t4:0 N => 3: t0:0, t4:0, t5:9
9,0,1,0,1,0 3 w0 t1:0; w1 t4:1 N => 3: t0:9, t1:0, t5:0
0,9 3 w0 t0:0 N => 3: t0:0, t1:9
1,2,1,2,1,2,1,9 4 w0 t2:1; w1 t6:1 N => 4: t0:1, t1:2, t6:1, t7:9

Please define SIZE mode as standard LTTB whose buckets are the windows:

  • Endpoints. Per partition and participant column, the first eligible point F and the last eligible point L that fall in a window are buckets of their own and are always returned, as in the original algorithm.
  • Window buckets. Every window contributes one bucket made of its other eligible points. The anchor chain starts at F. For a window bucket:
    • A is the previously selected point;
    • C is the average point of the next window bucket that has points, or L when there is none;
    • ties go to the earliest point;
    • a window bucket without points is skipped.
  • Output rows. F is reported in the first window that contains it, and L in the last one. Such a window emits up to two rows, or three when F and L share it. Rows are padded by position, as in M4.
  • Overlapping windows keep one anchor chain, as settled in the issue.

Expected results under this rule, computed with a reference implementation of it (count windows). Except for the SLIDE row, the time-window form (SIZE => 3ms, ORIGIN at the epoch) gives the same results. With SLIDE < SIZE, the time windows start one slide before the first point, so the layout differs:

input arguments expected
0,1,0,1,0,9 SIZE => 3 w0 t0:0, t1:1; w1 t4:0, t5:9
9,0,1,0,1,0 SIZE => 3 w0 t0:9, t1:0; w1 t4:1, t5:0
0,9 SIZE => 3 w0 t0:0, t1:9
1,2,1,2,1,2,1,9 SIZE => 4 w0 t0:1, t1:2; w1 t6:1, t7:9 (same as N => 4)
0,1,0,1,0,9 SIZE => 6 w0 t0:0, t4:0, t5:9 (same as N => 3)
0,1,0,1,0,9 SIZE => 3, SLIDE => 2 w0 t0:0, t1:1; w1 t2:0; w2 t4:0, t5:9
metrics d1 0,1,8,2,0,0 SIZE => 2 w0 t0:0, t1:1; w1 t2:8; w2 t4:0, t5:0
sparse 0,1,2,NULL,NULL,NULL,0,0,0 SIZE => 3 w0 t0:0, t2:2; w2 t6:0, t8:0

L is only known at the end of the partition. A window can therefore be finalized only once neither it nor its next window bucket can end with L, that is, after a later eligible point of the column arrives or the partition ends. This adds one eligible point of lookahead to the existing next-bucket lookahead.

2. A window or partition without any eligible point emits no row, as in M4.

At 213393e a window whose participant columns are all NULL still emits one all-NULL row, and in N mode an all-NULL partition does too. M4 emits no row for such a window (M4TableFunction.outputWindow, rowCount == 0). Both functions share the argument and window conventions, so their results should agree.

The all-NULL row carries no point. It only says that the window had input rows, and that depends on whether the input happens to contain all-NULL rows: WHERE v IS NOT NULL removes them, while a window without input rows emits nothing anyway. For 0,5,1,NULL,NULL,NULL with SIZE => 3, M4 returns window 0 only; this PR adds an all-NULL row for window 1.

Please emit as many rows as the column with the most selected points, and no row when that number is 0, in both modes. Today rowCount starts at 1 in TargetCountLTTBDataProcessor.finish (line 975), and emitWindow always writes a row (line 1246).

Updates. Please update the class Javadoc and the PR description, and these tests:

  • testCountWindowAndTimeWindowOutput: endpoints.
  • testWindowSkipsNullBucketForLookahead: window 1 emits no row.
  • testTargetCountPreservesEndpointsAndAlignsColumns: the d2 row.
  • testDuplicateTimestampsInDifferentPartitionsRemainValid: d2 has no value at t = 0, so it would no longer show a second partition. Please give it a non-NULL value.
  • The UTs that pin the current rule, such as testTwoEligiblePointsInOneBucketEmitOneRow, testWindowWithNoEligiblePointsStillEmitsARow and testOverlappingWindowsContinueTheAnchorChain.

…r merged partitions

- Introduce TableFunctionProcessorMemoryBudget in node-commons so only
  engine-internal providers declare a buffered-byte estimate; a user-defined
  table function no longer sees the new method on its provider interface.
  TableFunctionOperator now receives the resolved value, so the operator's
  reservation and the limit enforced by the processors always agree.
- Exclude LTTB from fragment-local execution alongside FFT: a partitioned
  GroupNode does not make a partition complete within one branch, so LTTB must
  run once over the ordered merged partitions. Read the ordering of per-branch
  GroupNodes from the node itself, since they are absent from nodeOrderingMap.
- Default lttb_buffer_size_in_bytes to 32 MiB, matching sort_buffer_size, and
  name the i18n message after the configuration key.
- Extend the IT, analyzer and processor tests to cover the new semantics.
…unction

# Conflicts:
#	iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanGenerator.java
The eight devices of one query partition each write t = 0..3, so every
timestamp occurs eight times in the partition. Their eight distinct series
slots are spread over the four DataRegionGroups, so the rows sharing a
timestamp are read from different regions and the duplicate becomes visible
only after the fragments are merged. The test asserts the 701 error in N,
SIZE and time-window mode, covering the cross-fragment path that no existing
case reached.
@DaZuiZui

Copy link
Copy Markdown
Contributor Author

@JackieTien97 thank review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature request] Add LTTB downsampling as a built-in table function for the Table Model

2 participants