Repository navigation
[fix](ivm) Read a base partition with no stream offset from the base table in an IVM refresh - #69778
Conversation
…table in an IVM refresh
### What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary:
An IVM materialized view reads each base table through an internal table
stream, and the stream records an offset for a partition only when it consumes
the partition with data in it. Two kinds of partitions therefore have no
offset, and none of their rows are in the MV: a partition added after the
stream was created, and a partition that was still empty when the stream was
created and whenever it was consumed. A COMPLETE refresh records offsets only
for the partitions that have data, so a range partition created ahead of its
data stays without one.
`NormalizeOlapTableStreamScan.makeTableStreamScan` reads such a partition from
its changelog with no start, and `applyChangelogTtl` turns a missing start
into the first TSO the changelog retains. Once the partition's rows are older
than `changelog.ttl_seconds` when the MV is refreshed, they are skipped: the
refresh succeeds, the offset is recorded past them, and no later refresh reads
them. A query on the base table with the MV's shape is answered from the MV by
transparent rewrite and returns the same wrong result. An MV partitioned like
its base table rebuilds the MV partition of an added base partition, but its
partition that was empty when the MV was built is caught up incrementally, so
it loses those rows too.
How to reproduce: a range-partitioned table with the changelog enabled and a
TTL of a day, with rows in one partition only; an IVM MV on it, refreshed; then
add a partition, write rows to it and to the partition that was empty, set
`changelog.ttl_seconds` to 1, wait two seconds and run
`REFRESH MATERIALIZED VIEW mv INCREMENTAL`.
| MV | Before | After |
| --- | --- | --- |
| non-partitioned, over a unique-key table | SUCCESS, the rows of both partitions missing for good | SUCCESS, the MV matches the base table |
| append-only detail MV, over a duplicate-key table | SUCCESS, the rows of both partitions missing for good | SUCCESS, the MV matches the base table |
| partitioned like its unique-key base table | SUCCESS, the added partition rebuilt, the rows of the one that was empty missing | SUCCESS, the MV matches the base table |
The fix makes an IVM read take an incremental partition with no consumption
baseline (no offset, or the sentinel offset of a partition empty at stream
creation) from the base table, the way it already takes a historical partition
of a stream created with `show_initial_rows`: every row as an append, with the
commit TSO as the sequence and the row LSN as the row identity. For an MV that
holds none of the partition's rows that is the partition's whole change,
whatever the TTL, and the offset the refresh records is the same as before. In
shared-nothing mode the base read and the end TSO it commits are both taken
under the table read lock of planning, as for a historical partition; in cloud
mode the base read is pinned to the visible version of the stream read state.
The choice is made by the statement being an IVM rewrite: a user stream keeps
reading the changelog it retains, which is what it promises.
### Release note
Fix an IVM materialized view missing the rows of a base partition that was
added after the MV was created, or was empty then, when those rows were older
than the table's `changelog.ttl_seconds` at refresh time.
### Check List (For Author)
- Test: Regression test / Unit Test
- Regression test: `mtmv_p0/ivm/test_ivm_partition_without_offset` (new,
nonConcurrent): a non-partitioned MV over a unique-key table, an
append-only detail MV over a duplicate-key table and an MV partitioned
like its unique-key base table, each refreshed after rows written to an
added partition and to the partition that was empty became older than
the TTL, then again after more changes with the TTL back to a day, and
compared with the base table with MV rewrite off. Run on a local
shared-nothing cluster, first on a build without this change, where all
three refreshes succeeded without those rows; with the change the suite
passes, and passes again against the generated `.out`.
On the same build the whole `mtmv_p0/ivm` directory (113 suites),
`mtmv_ivm_restart_p0` and `changelog_p0/test_changelog_before_column_name`
pass.
Not run on a cloud cluster.
- Unit Test: `ExplainTableStreamPlanTest` (two new cases: a user stream
reads a partition without a consumption baseline from the changelog, the
same read in an IVM statement takes it from the base table and still
reads the partition with an offset from the changelog).
Run together with `IvmIncrRefreshMTMVTest`, `IvmFullRefreshMTMVTest`,
`InsertIntoTableCommandTableStreamTest`, `UpdateMvByPartitionCommandTest`
and `CollectRelationChangelogTtlTest`: 84 tests pass.
- Behavior changed: Yes. An IVM refresh reads a partition with no stream
offset from the base table instead of from its changelog; for such a
partition `EXPLAIN REFRESH ... INCREMENTAL` shows a scan of the base table.
- Does this need documentation: No
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Thank you for your contribution to Apache Doris. Please clearly describe your PR:
|
|
/review |
|
run buildall |
There was a problem hiding this comment.
Reviewed all four changed files at head 7549bc2bb2184cee197e66ee0c905c3d50f272e9 through two rounds, including independent production, test, and baseline/snapshot investigations. One P2 finding concerns the new unit-test fixture's commit timestamps. No production correctness defect or P0/P1 blocker was found. There were no existing inline findings to carry forward and no additional user-provided focus points.
Critical checkpoint conclusions:
- Goal/data correctness: The IVM-only classification supplies the complete current partition image where the pre-refresh image is empty, preventing TTL truncation. The added FE and regression cases target this behavior.
- Scope/reuse: The localized change reuses the existing historical scan, projections, delete-sign filter and union helpers.
- Concurrency/locking: Shared-nothing planning holds the base read lock through offset/version capture and scan finalization. Cloud scans use the visible version paired with the read-state end TSO. No new lock, thread, lock-order change or locked IO is introduced.
- Lifecycle: Added collections are statement-local. Initial, consumed, reset, partial-rebuild and failed-refresh baseline transitions were traced; no new lifetime or initialization issue was found.
- Configuration: No configuration item is added.
- Compatibility: No public symbol, persisted metadata or storage format changes.
- Thrift/protobuf: No definitions or field/enum IDs change.
- Parallel paths: Both unique-key and append-only detail IVM models receive the fix. User streams retain their behavior; RESET/SNAPSHOT bypass this branch. Shared-nothing and cloud paths were inspected.
- Conditional logic: The IVM gate and no-baseline premise are documented and agree with snapshot exclusion of these partitions.
- Test coverage: Added tests cover user versus IVM routing, unique/detail/partitioned MVs, added and initially empty partitions past TTL, and subsequent offset consumption. New NULL, join, aggregate and cloud-concurrency cases are absent; those interactions were examined statically.
- Expected results: All 17 query/output sections, business rows, ordering and task-mode expectations match their producers. The inline finding identifies a timing-dependent fixture failure. Output-generation provenance and test execution were not verified here.
- Observability: Existing EXPLAIN scans and refresh task/fallback status expose the changed route adequately.
- Transactions/persistence: The original analyzed wrapper still supplies offset updates through existing transaction attachments; commit/replay and cloud atomic rowset/offset handling remain in use.
- Writes/crashes: The rule changes read routing. Existing offset/data commit boundaries and durable rebuild requirements cover retries and interrupted rebuilds; no new write/failure boundary was found.
- FE/BE propagation: No transmitted variable or protocol path is added.
- Performance/memory: Classification is linear in selected partitions using set membership. A complete base scan is required to recover rows outside changelog retention; no new BE ownership or allocation issue applies.
- Other issues: Final whole-PR sweep found no additional valuable issue; error/precondition behavior remains unchanged.
The review converged in round 2: all three reviewers returned NO_NEW_VALUABLE_FINDINGS in the latest round, every candidate and initial risk has a recorded disposition, and no concrete question remains unresolved. Validation was static only: no builds, tests, benchmarks or runtime reproduction were performed, and the fixture failure frequency was not measured.
Review converged after 2 round(s).
When does the problem this PR fixes show up? Three examplesRows go missing only when three things hold at the same time:
Example 1: daily partitions, refreshed once a weekCREATE TABLE orders (
order_id BIGINT NOT NULL,
dt DATE NOT NULL,
amount DECIMAL(12, 2)
)
UNIQUE KEY(order_id, dt)
PARTITION BY RANGE(dt) ()
DISTRIBUTED BY HASH(order_id) BUCKETS 8
PROPERTIES (
"enable_unique_key_merge_on_write" = "true",
"changelog.enable" = "true",
"changelog.need_historical_value" = "true",
"dynamic_partition.enable" = "true",
"dynamic_partition.time_unit" = "DAY",
"dynamic_partition.start" = "-30",
"dynamic_partition.end" = "3",
"dynamic_partition.prefix" = "p"
); -- changelog.ttl_seconds is not set: one day
CREATE MATERIALIZED VIEW sales_by_day
BUILD IMMEDIATE REFRESH INCREMENTAL ON SCHEDULE EVERY 7 DAY STARTS '2026-11-02 02:00:00'
DISTRIBUTED BY HASH(dt) BUCKETS 1
AS SELECT dt, COUNT(*) AS cnt, SUM(amount) AS total FROM orders GROUP BY dt;Orders are loaded in batches during the day (09:00 to 18:00) into that day's partition, and old partitions are not modified:
After that refresh the seven partitions have offsets at their latest TSO, so the six missing days are never read again; only a manual COMPLETE refresh brings them back. The same happens every week. Example 2: refreshes interrupted for a few daysSame table, with the MV refreshed every night at 02:00. Normally every partition is first read less than a day after its rows were written, so nothing is lost. Now the refresh fails three nights in a row for an unrelated reason, or the job is paused during an upgrade. When it succeeds on the fourth night, the partitions of those days were never consumed: they have no offsets, they are read from "now minus one day", and only the last day reaches the MV. What the user sees is a refresh that recovered. Example 3: a partition that existed, empty, when the MV was built; a partitioned (PCT) MV is affected tooWith CREATE MATERIALIZED VIEW sales_pmv
BUILD IMMEDIATE REFRESH INCREMENTAL ON SCHEDULE EVERY 7 DAY
PARTITION BY (dt)
DISTRIBUTED BY HASH(dt) BUCKETS 1
AS SELECT dt, order_id, amount FROM orders;
A LIST partition behaves the same way: When it does not happen
The new regression test |
TPC-H: Total hot run time: 28513 ms |
Steps to reproduceOne unique-key table, one non-partitioned IVM MV, one BE. The FE needs CREATE DATABASE IF NOT EXISTS ivm_repro;
USE ivm_repro;
-- Keep the queries on t answered by t itself; step 6 turns this back on.
SET enable_materialized_view_rewrite = false;
DROP MATERIALIZED VIEW IF EXISTS mv;
DROP TABLE IF EXISTS t;
CREATE TABLE t (
k BIGINT NOT NULL,
dt DATE NOT NULL,
v BIGINT
)
UNIQUE KEY(k, dt)
PARTITION BY RANGE(dt) (
PARTITION p202601 VALUES [('2026-01-01'), ('2026-02-01')),
PARTITION p202602 VALUES [('2026-02-01'), ('2026-03-01'))
)
DISTRIBUTED BY HASH(k) BUCKETS 1
PROPERTIES (
"replication_num" = "1",
"enable_unique_key_merge_on_write" = "true",
"changelog.enable" = "true",
"changelog.need_historical_value" = "true",
"changelog.ttl_seconds" = "86400"
);
INSERT INTO t VALUES (1, '2026-01-10', 10); -- p202602 stays empty for now
-- 1. Build the MV. Its first refresh is a COMPLETE one: p202601 gets a stream offset, the empty p202602 gets none.
CREATE MATERIALIZED VIEW mv
BUILD IMMEDIATE REFRESH INCREMENTAL ON MANUAL
DISTRIBUTED BY HASH(k) BUCKETS 1
PROPERTIES ("replication_num" = "1")
AS SELECT k, dt, v FROM t;
-- repeat until Status is SUCCESS
SELECT Status, RefreshMode FROM tasks('type'='mv')
WHERE MvDatabaseName = 'ivm_repro' AND MvName = 'mv' ORDER BY CreateTime DESC LIMIT 1;
-- 2. Rows in the partition that was empty and in a partition added after the MV: neither partition has an offset.
ALTER TABLE t ADD PARTITION p202603 VALUES [('2026-03-01'), ('2026-04-01'));
INSERT INTO t VALUES (2, '2026-02-10', 20), (3, '2026-03-10', 30);
-- 3. Let those rows grow older than the TTL before the MV reads them. This stands in for a refresh that comes
-- more than a day after the write with the default TTL of one day.
ALTER TABLE t SET ("changelog.ttl_seconds" = "1");
SELECT sleep(2);
-- 4. A strict incremental refresh; repeat the query until the task has finished.
REFRESH MATERIALIZED VIEW mv INCREMENTAL;
SELECT Status, RefreshMode, IvmFallbackReason, ErrorMsg FROM tasks('type'='mv')
WHERE MvDatabaseName = 'ivm_repro' AND MvName = 'mv' ORDER BY CreateTime DESC LIMIT 1;
-- 5. Compare the MV with the table.
SELECT * FROM mv ORDER BY k;
SELECT * FROM t ORDER BY k;
-- 6. With transparent rewrite on, the same query on t is answered from mv.
SET enable_materialized_view_rewrite = true;
SELECT k, dt, v FROM t ORDER BY k;
EXPLAIN SHAPE PLAN SELECT k, dt, v FROM t ORDER BY k;What it printed on a local shared-nothing cluster:
So without the fix the refresh reports success, the MV is short of both rows for good (their partitions now have offsets past them), and the query on Variants, all in |
TPC-DS: Total hot run time: 150727 ms |
ClickBench: Total hot run time: 24.62 s |
FE UT Coverage ReportIncrement line coverage |
FE Regression Coverage ReportIncrement line coverage |
| List<Long> incrementalPartitionIds = | ||
| streamWrapper.filterIncrementalPartitionIds(scan.getSelectedPartitionIds()); | ||
| if (cascadesContext.getStatementContext().isIvmMTMVRewrite()) { | ||
| // A partition with no consumption baseline -- added after the stream was created, or still empty when |
There was a problem hiding this comment.
这个感觉不是IVM 专有的,IVM 只是用Stream 功能,如果有BUG,这里对非IVM也是适用的。 read from stream 本来就应该那些没有offset的新分区
…cate-key table (#69785) ### What problem does this PR solve? Issue Number: None Related PR: #69772 Problem Summary: **In short.** An IVM materialized view over a duplicate-key base table silently misses rows once the table's `changelog.ttl_seconds` has expired its stream offset: the refresh succeeds, the rows written between the offset and the cutoff are never read, and a base-table query of the MV's shape that transparent rewrite answers from the MV returns the same wrong result. This PR makes every read of an IVM refresh reject an expired offset, whatever its scan type, so the refresh fails with `CHANGELOG_BROKEN` or, when it may fall back, rebuilds the MV with COMPLETE (#69772). It also carries the two follow-ups yujun777 suggested on #69772. **Background.** An IVM MV reads each base table through an internal table stream. A duplicate-key table is read `APPEND_ONLY`: `OlapTableStreamWrapper.getStreamScanType()` returns `APPEND_ONLY` for `DUP_KEYS`, whatever type the stream declares. `NormalizeOlapTableStreamScan.applyChangelogTtl` decides what an expired start means: a `MIN_DELTA` read is rejected (#69772 turns that into `CHANGELOG_BROKEN` and COMPLETE), any other read is clipped to the first TSO the changelog retains. The clipping is right for a user stream, which reads the changelog it retains. **The problem, and what it cost.** - For an IVM refresh the clipping loses data without a trace. The rows written between the offset and the cutoff are never read, the refresh succeeds, the offset moves past them, and no later incremental refresh brings them back. - It hits every IVM MV over a duplicate-key table, the aggregate ones (unique-key MV) and the append-only detail ones alike, whenever the MV is not refreshed within the TTL of a write. **How this PR fixes it.** 1. `applyChangelogTtl` rejects an expired start for every read of an IVM statement (the statement carries an `IvmRewriteContext`), whatever the scan type. The condition is the statement, not the type the stream declares: a user stream on a duplicate-key table also declares `MIN_DELTA` by default, and it keeps its clipping. An IVM statement never reads a user stream: an MV query that scans a stream is rejected when the MV is created. The snapshot read of a duplicate-key table is bounded by the offset on the base table itself and reads no changelog, so it is unchanged. 2. The follow-ups from the review of #69772, in their own commit and without a behavior change: - the rebuild an incremental attempt runs before its delta now turns a failure into a fallback when its reason's only repair is COMPLETE (`IvmFailureReason.requiresCompleteRefresh()`), and passes that reason through, instead of naming `CHANGELOG_BROKEN`; - the `CHANGELOG_BROKEN` branch of `handleIvmFallbackResult`, which returned the same result as the `requiresCompleteRefresh()` branch after it, is removed; why only COMPLETE repairs an expired changelog now sits on `IvmFailureReason.CHANGELOG_BROKEN`. **Results** (local shared-nothing cluster; a duplicate-key table, a row written after the MV's offset, then `changelog.ttl_seconds` set to 1 and two seconds of waiting): | Refresh after the offset expired | Before | After | | --- | --- | --- | | `REFRESH ... INCREMENTAL` (strict) | SUCCESS, the row missing from the MV | FAILED, `IvmFallbackReason = CHANGELOG_BROKEN`, the MV keeps its rows | | `REFRESH ... AUTO` | SUCCESS with nothing left to read; the row stays missing | SUCCESS through COMPLETE, `IvmFallbackReason = CHANGELOG_BROKEN`, the MV matches the base table | | the next refresh, with the TTL back to a day | incremental; the row missing for good | incremental | | a user stream on the same table | reads from the first retained TSO | the same | Both an aggregate MV and an append-only detail MV over the table behave as above. **Classes, and how they call each other.** - `IvmIncrRefreshMTMV` → `IvmDeltaRewriteState.createDeltaScan` → `LogicalOlapTableStreamScan` → `NormalizeOlapTableStreamScan.makeTableStreamScan` → `makeIncrementalScanFromChangelog` → `applyChangelogTtl` → `ChangelogUtils.effectiveStartTso`, which now rejects for an IVM statement. - The rejection reaches `IvmIncrRefreshManager.doRefresh` → `IvmFailureClassifier.classifyExecutionFailure` → `CHANGELOG_BROKEN` → `MTMVTask.handleIvmFallbackResult` → strict: failure with the reason; otherwise `FALLBACK_TO_COMPLETE`. - The rebuild before the delta: `MTMVTask.executeIvmAttempt` → `executePartitionBasedRefresh` → on a failure whose reason `requiresCompleteRefresh()`, `handleIvmFallbackResult` with that reason. Not changed: user streams, direct incremental queries (`BindRelation.applyChangelogTtl`), the snapshot and reset reads, and anything persisted. A partition with no stream offset is a separate problem, fixed by #69778.
…inTableStreamPlanTest (#69809) ### What problem does this PR solve? Issue Number: None Related PR: #69778, #69785 Problem Summary: **In short:** the FE build on master fails, because `ExplainTableStreamPlanTest` now declares the same private method twice. This PR deletes the second copy; nothing else changes. #69778 and #69785 each added the same private helper `normalizeStreamRead(String, long, Optional<IvmRewriteContext>)` to `ExplainTableStreamPlanTest`, at different places in the file. Each PR compiled on its own, and they were merged 41 seconds apart without a rebase onto each other, so master has both copies, and javac rejects the file: ``` ExplainTableStreamPlanTest.java:843: error: method normalizeStreamRead(String,long,Optional<IvmRewriteContext>) is already defined in class ExplainTableStreamPlanTest ``` `build.sh --fe` passes `-DskipTests`, which skips running the tests but still compiles them, so this fails the whole FE build on master and on every PR based on it, not only the FE unit tests. The fix removes the copy #69785 added after its own tests. The two copies are identical, so the tests of both PRs keep calling the same code.
What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary:
In short. An IVM materialized view silently misses the rows of a base partition its internal stream has no offset for (a partition added after the MV was built, or one that was still empty then) when those rows are older than the base table's
changelog.ttl_secondsat refresh time. The refresh succeeds, the rows are never read, and a base-table query of the MV's shape that transparent rewrite answers from the MV returns the same wrong result. This PR makes an IVM read take such a partition from the base table, as an append of all its rows, instead of from its changelog.Background. An IVM MV reads each base table through an internal table stream. The stream keeps an offset per partition and records one only when it consumes the partition with data in it. It is created with
show_initial_rows, so a partition that has data at creation is a historical partition, read once from the base table; a partition that is empty then gets nothing; and every consumption, the reset of a COMPLETE refresh included, records offsets only for the partitions it read with data. Two kinds of partitions therefore have no offset and none of their rows in the MV: a partition added after the stream was created, and a partition that was empty when the stream was created and at every consumption since (a range partition created ahead of its data is the usual one).The problem, and what it cost.
NormalizeOlapTableStreamScan.makeTableStreamScanreads a partition with no offset like any other incremental partition: from its changelog, with no start.applyChangelogTtlturns the missing start into the first TSO the changelog retains and does not reject it.How this PR fixes it.
makeTableStreamScanmoves the incremental partitions that have no consumption baseline (OlapTableStreamWrapper.filterConsumedPartitionIds: no offset, or the sentinel offset of a partition empty at stream creation) to the branch that reads historical partitions from the base table: every row as an append, with the commit TSO as the sequence and the row LSN as the row identity. For an MV that holds none of the partition's rows that is the partition's whole change, whatever the TTL. The offset the refresh records afterwards is the same as before.Results (local shared-nothing cluster; rows written to an added partition and to the partition that was empty, older than a one-second TTL when the MV is refreshed):
Classes, and how they call each other.
IvmIncrRefreshMTMV→IvmDeltaRewriteState.createDeltaScanbuilds aLogicalOlapTableStreamScanover anOlapTableStreamWrapper.NormalizeOlapTableStreamScan.makeTableStreamScan→filterHistoryPartitionIds/filterIncrementalPartitionIds, and in an IVM statementfilterConsumedPartitionIds→ historical and unconsumed partitions:makeOlapScanOnBaseTable; consumed partitions:makeIncrementalScanFromChangelog→applyChangelogTtl.StreamConsumptionInfoExtractorrecords the new offsets from the wrapper's output map, as before.Not changed: the TTL check, the snapshot and reset reads (the snapshot read already leaves the partitions without a baseline out and reports that it did), user streams, and anything persisted.
Release note
Fix an IVM materialized view missing the rows of a base partition that was added after the MV was created, or was empty then, when those rows were older than the table's
changelog.ttl_secondsat refresh time.Check List (For Author)
mtmv_p0/ivm/test_ivm_partition_without_offset(new, nonConcurrent): a non-partitioned MV over a unique-key table, an append-only detail MV over a duplicate-key table and an MV partitioned like its unique-key base table, each refreshed after rows written to an added partition and to the partition that was empty became older than the TTL, then again after more changes with the TTL back to a day, and compared with the base table with MV rewrite off. Local shared-nothing cluster: first on a build without this change, where all three refreshes succeeded without those rows; with the change the suite passes, and passes again against the generated.out. On the same build the wholemtmv_p0/ivmdirectory (113 suites),mtmv_ivm_restart_p0andchangelog_p0/test_changelog_before_column_namepass. After the rebase onto the latest master (two commits that touch neither this code nor IVM), the new suite and the othertest_ivm_partition_*suites pass again. Not run on a cloud cluster.ExplainTableStreamPlanTest(two new cases: a user stream reads a partition without a consumption baseline from the changelog; the same read in an IVM statement takes it from the base table and still reads the partition with an offset from the changelog), run together withIvmIncrRefreshMTMVTest,IvmFullRefreshMTMVTest,InsertIntoTableCommandTableStreamTest,UpdateMvByPartitionCommandTestandCollectRelationChangelogTtlTest: 84 tests pass (before the rebase).EXPLAIN REFRESH ... INCREMENTALshows a scan of the base table.