Skip to content

[fix](ivm) Reject an expired stream offset for an IVM read of a duplicate-key table - #69785

Merged
morningman merged 2 commits into
apache:masterfrom
morningman:changelog-monitor-t10
Oct 29, 2026
Merged

morningman merged 2 commits into
apache:masterfrom
morningman:changelog-monitor-t10

Conversation

@morningman

Copy link
Copy Markdown
Contributor

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 [fix](ivm) Fall back to COMPLETE when an IVM stream offset has expired #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.

Release note

Fix an IVM materialized view over a duplicate-key base table silently missing rows after the table's changelog.ttl_seconds expired the stream offset. A refresh that allows fallback now rebuilds the MV with COMPLETE; a strict INCREMENTAL refresh fails with reason CHANGELOG_BROKEN.

Check List (For Author)

  • Test: Regression test / Unit Test
    • Regression test: mtmv_p0/ivm/test_ivm_changelog_offset_expired_dup (new, nonConcurrent): an aggregate MV and an append-only detail MV over one duplicate-key table, strict and AUTO refreshes after the offset expired and a refresh after the TTL is restored, each MV compared with the base table; and a user stream on the table, which still reads past its expired offset. Local shared-nothing cluster: first on a build without this change, where both strict refreshes succeeded without the row and the MVs never caught up; with the change the suite passes, and passes again against the generated .out. With both commits, the whole mtmv_p0/ivm directory passes (114 suites, test_ivm_changelog_offset_expired of [fix](ivm) Fall back to COMPLETE when an IVM stream offset has expired #69772 included). Not run on a cloud cluster.
    • Unit Test: ExplainTableStreamPlanTest (two new cases: a user stream read APPEND_ONLY clips its expired offset to the first retained TSO; the same read in an IVM statement is rejected) and MTMVTaskTest (a new case: a rebuild failure that COMPLETE does not repair still fails an attempt that may fall back), run with the IVM unit tests (org.apache.doris.mtmv.ivm, CreateMTMVCommandTest, ShowCreateMTMVTest, DropMaterializedViewTest, ChangelogUtilsTest): 630 tests pass.
  • Behavior changed: Yes. An IVM refresh over a duplicate-key table whose stream offset expired falls back to COMPLETE, or fails if it is strict, instead of succeeding without the rows. This includes a partition that was only idle for longer than the TTL before a new write, which an IVM MV over a unique-key table already handles this way; keeping the offsets of caught-up partitions alive is a separate change.
  • Does this need documentation: No

morningman and others added 2 commits October 28, 2026 23:59
…cate-key table

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69772

Problem Summary:

An IVM materialized view reads each base table through an internal table
stream. A duplicate-key base table is read APPEND_ONLY:
`OlapTableStreamWrapper.getStreamScanType()` returns APPEND_ONLY for DUP_KEYS,
whatever type the stream declares. When the base table's
`changelog.ttl_seconds` has expired the stream offset,
`NormalizeOlapTableStreamScan.applyChangelogTtl` rejects the read only for
MIN_DELTA; for APPEND_ONLY it clips the start to the first retained TSO.

For a user stream that clipping is the intended behavior: the stream reads
what the changelog still holds. For an IVM refresh it 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. A query on the base table with the MV's shape is answered
from the MV by transparent rewrite, so it returns the same wrong result.

How to reproduce: create a duplicate-key base table with the changelog enabled
and `changelog.ttl_seconds` of a day, build an IVM MV on it (an aggregate MV, or
an append-only detail MV), refresh it, insert a row, set
`changelog.ttl_seconds` to 1, wait two seconds and run
`REFRESH MATERIALIZED VIEW mv INCREMENTAL`.

| Refresh after the offset expired | Before | After |
| --- | --- | --- |
| `REFRESH ... INCREMENTAL` (strict) | SUCCESS, the inserted row is 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 is missing for good | incremental |

The fix rejects an expired start for every read of an IVM statement, whatever
its scan type. It is the error a MIN_DELTA read already raises, which apache#69772
classifies as `CHANGELOG_BROKEN` and answers with COMPLETE, the only refresh
that resets the stream baselines. The condition is the statement (it carries an
`IvmRewriteContext`), 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.

### Release note

Fix an IVM materialized view over a duplicate-key base table silently missing
rows after the table's `changelog.ttl_seconds` expired the stream offset. A
refresh that allows fallback now rebuilds the MV with COMPLETE; a strict
INCREMENTAL refresh fails with reason `CHANGELOG_BROKEN`.

### Check List (For Author)

- Test: Regression test / Unit Test
    - Regression test: `mtmv_p0/ivm/test_ivm_changelog_offset_expired_dup`
      (new, nonConcurrent): an aggregate MV and an append-only detail MV over
      one duplicate-key table, strict and AUTO refreshes after the offset
      expired and a refresh after the TTL is restored, each MV compared with
      the base table with MV rewrite off; and a user stream on the table, which
      still reads past its expired offset. Run on a local shared-nothing
      cluster, first on a build without this change, where both strict
      refreshes succeeded without the row and the MVs never caught up; with the
      change the suite passes, and passes again against the generated `.out`.
      The whole `mtmv_p0/ivm` directory (114 suites) passes on the same
      build. Not run on a cloud cluster.
    - Unit Test: `ExplainTableStreamPlanTest` (two new cases: a user stream
      read APPEND_ONLY clips its expired offset to the first retained TSO, and
      the same read in an IVM statement is rejected), run with the IVM unit
      tests (`org.apache.doris.mtmv.ivm`, `MTMVTaskTest`,
      `CreateMTMVCommandTest`, `ShowCreateMTMVTest`,
      `DropMaterializedViewTest`, `ChangelogUtilsTest`): 630 tests pass.
- Behavior changed: Yes. An IVM refresh over a duplicate-key table whose
  stream offset expired falls back to COMPLETE, or fails if it is strict,
  instead of succeeding without the rows. This includes a partition that was
  only idle for longer than the TTL before a new write, which a unique-key IVM
  MV already handles this way; keeping the offsets of caught-up partitions
  alive is a separate change.
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…eRefresh alone

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69772

Problem Summary:

A follow-up of the review of apache#69772. Two places in `MTMVTask` were written for
the one reason that change produced, `CHANGELOG_BROKEN`, rather than for the
property that makes their handling right: the reason's only repair is COMPLETE
(`IvmFailureReason.requiresCompleteRefresh()`).

- The rebuild an incremental attempt runs before its delta turned a failure
  into a fallback only when it classified as `CHANGELOG_BROKEN`, and built the
  fallback with that reason hard-coded. It now filters on
  `requiresCompleteRefresh()` and passes the classified reason through. That
  property is what makes the conversion valid there: the rebuild already is the
  partition refresh, so there is nothing to fall back to short of COMPLETE, and
  the sync-and-retry the delta has for a missing MV partition does not exist on
  this path. Any other failure still fails the attempt. No other reason that
  requires COMPLETE can come out of the rebuild today (the min/max and bitmap
  guards are only built into delta plans), so the behavior is unchanged.
- `handleIvmFallbackResult` had a `CHANGELOG_BROKEN` branch in front of the
  `requiresCompleteRefresh()` one that returned the same
  `FALLBACK_TO_COMPLETE` with a differently worded log. It is removed. Why
  COMPLETE is the only repair of an expired changelog (a partition refresh
  still reads the streams at their offsets) now sits on
  `IvmFailureReason.CHANGELOG_BROKEN`, and `requiresCompleteRefresh()` says
  what the set means.

### Release note

None

### Check List (For Author)

- Test: Unit Test / Regression test
    - Unit Test: `MTMVTaskTest` has a new case: a rebuild failure that
      COMPLETE does not repair (a missing MV partition) still fails an attempt
      that may fall back. The two expired-offset cases from apache#69772 run through
      the generalized helper and pass unchanged. With the IVM unit tests: 630 tests pass.
    - Regression test: `mtmv_p0/ivm/test_ivm_changelog_offset_expired`, which
      drives both paths, passes unchanged on a local shared-nothing cluster,
      with the whole `mtmv_p0/ivm` directory.
- Behavior changed: No. The fallback for an expired changelog logs the same
  line as the other reasons that go to COMPLETE.
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@morningman

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot 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.

Reviewed all seven changed files at 0d4e500f26bfba4918e20386eedb39784065699d. No new actionable findings, and no existing inline P0/P1 findings to carry forward. No additional user review focus was supplied.

Critical checkpoint conclusions:

  • Goal and evidence: initialized, expired APPEND_ONLY stream offsets now fail within IVM planning instead of silently clipping. The real incremental and rebuild statement contexts reach the changed predicate. Unit and regression cases cover the intended behavior; they were inspected, not executed.
  • Scope and reuse: production changes are focused and reuse the existing expiration helper, failure classifier and requiresCompleteRefresh dispatch.
  • Concurrency and locking: no new threads, shared mutable state, locks or lock-order changes; the new boolean and Optional are local to existing statement/task flows.
  • Lifecycle and initialization: the same statement context is passed into command execution and planning; existing context cleanup is retained. No new resource ownership or static initialization dependency.
  • Configuration: no configuration added; the existing table TTL and statement reference TSO are reused.
  • Compatibility: no function signature, persisted format or enum value change; no new rolling-upgrade requirement introduced.
  • Thrift/protobuf: no generated-definition changes, so the cross-branch field-id checkpoint is not applicable.
  • Parallel paths: examined DUP and non-DUP deltas/snapshots, historical and RESET reads, ordinary user streams, direct incremental queries, dirty-partition rebuilds, COMPLETE, EXPLAIN/dry run and cloud/shared-nothing read-state wiring. RESET and DUP snapshots bypass changelog expiration appropriately; ordinary stream clipping remains unchanged.
  • Conditional checks and errors: comments explain why IVM identity is required in addition to MIN_DELTA. Strict requests throw; fallback-enabled requests skip PARTITIONS when COMPLETE is required. Unknown and missing-target-partition rebuild failures remain failures, and the actual classified reason is preserved.
  • Test coverage: new planner tests distinguish ordinary clipping from IVM rejection. Rebuild tests retain strict/allowed expiration coverage and add a non-repairable-failure case. The regression suite checks aggregate and detail MVs, ordinary streams, strict failure, AUTO recovery and the next incremental refresh, with both transparent-rewrite paths disabled.
  • Expected results: all 20 regression query labels match the output file in order. Ordered rows, aggregate arithmetic, task status and fallback reasons are consistent with the code. Runtime correctness and generated-output provenance were not independently established.
  • Observability: existing errors retain start/cutoff TSOs; fallback warnings retain MV/task identifiers, actual reason and detail; task fields expose the reason. No additional observability requirement identified for this gate.
  • Transactions and persistence: no journal, replay or transaction protocol change. Failed planning/batches are not credited with successful snapshots or epochs; existing rebuild requirements and held-record publication barriers remain.
  • Writes and atomicity: existing insert/overwrite and stream-offset commit paths are preserved. COMPLETE clears prior held records and rebuilds through the established baseline-reset path; a failed COMPLETE still fails the task.
  • FE/BE propagation: no new transmitted variable or send path; scan wrappers and pinned cloud visible versions are preserved.
  • Performance: one context-presence check plus the existing linear offset transformation; no new RPC, repeated scan or retry loop. COMPLETE recovery intentionally incurs recomputation for correctness.
  • Other invariants and limits: no introduced data-visibility, memory-safety or swallowed-error issue substantiated. Missing/null-offset handling is unchanged and was dismissed as a separate pre-existing case, rather than reported as a new defect.

Converged after one review round: both complete-review agents and the separate risk-focused agent returned NO_NEW_VALUABLE_FINDINGS; all changed files and risk items have evidence-backed dispositions, with no unresolved new candidate. Validation was static only: no builds, tests, benchmarks, source edits or submodule initialization.

Review converged after 1 round(s).

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28225 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 0d4e500f26bfba4918e20386eedb39784065699d, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17632	5154	4997	4997
q2	2114	305	252	252
q3	10268	1356	804	804
q4	4677	456	336	336
q5	7423	786	523	523
q6	180	182	150	150
q7	731	787	597	597
q8	9256	1310	1132	1132
q9	5447	4155	4150	4150
q10	6808	1272	1012	1012
q11	419	243	226	226
q12	638	411	281	281
q13	18090	3488	3017	3017
q14	268	271	241	241
q15	q16	713	708	666	666
q17	1173	746	577	577
q18	7011	6160	6105	6105
q19	1122	959	600	600
q20	390	331	223	223
q21	4288	2085	2466	2085
q22	340	289	251	251
Total cold run time: 98988 ms
Total hot run time: 28225 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	5576	5521	5515	5515
q2	652	554	540	540
q3	5183	5373	4923	4923
q4	1967	2113	1556	1556
q5	5256	5158	5144	5144
q6	232	184	140	140
q7	2536	2098	1845	1845
q8	3001	2628	2643	2628
q9	8175	8055	7987	7987
q10	3730	3634	3402	3402
q11	606	421	404	404
q12	694	693	525	525
q13	3593	3753	3281	3281
q14	332	318	287	287
q15	q16	686	734	656	656
q17	8294	6946	6829	6829
q18	12951	12212	12850	12212
q19	918	779	790	779
q20	2128	2115	1907	1907
q21	5524	4542	4653	4542
q22	511	442	416	416
Total cold run time: 72545 ms
Total hot run time: 65518 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 150934 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 0d4e500f26bfba4918e20386eedb39784065699d, data reload: false

query5	4320	574	441	441
query6	425	224	192	192
query7	4803	436	224	224
query8	325	160	146	146
query9	8744	3251	3249	3249
query10	463	318	256	256
query11	6017	3858	3554	3554
query12	149	92	85	85
query13	1256	432	320	320
query14	6142	3884	3631	3631
query14_1	3400	3350	3330	3330
query15	202	207	178	178
query16	930	421	388	388
query17	838	649	509	509
query18	2442	425	293	293
query19	190	156	131	131
query20	82	87	82	82
query21	206	129	108	108
query22	12940	12878	12769	12769
query23	13493	12727	12336	12336
query23_1	12591	12412	12543	12412
query24	7393	807	464	464
query24_1	476	485	484	484
query25	535	419	366	366
query26	1260	259	136	136
query27	2765	435	272	272
query28	4634	2065	2075	2065
query29	1541	599	456	456
query30	302	222	187	187
query31	1014	920	802	802
query32	136	93	94	93
query33	536	304	245	245
query34	874	856	520	520
query35	730	738	632	632
query36	827	794	735	735
query37	120	99	80	80
query38	1782	1745	1682	1682
query39	792	791	750	750
query39_1	733	739	743	739
query40	220	117	98	98
query41	72	70	67	67
query42	87	83	83	83
query43	352	370	311	311
query44	1331	719	729	719
query45	183	190	172	172
query46	829	972	568	568
query47	2859	2872	2718	2718
query48	306	313	225	225
query49	577	417	294	294
query50	680	285	211	211
query51	10424	10679	10311	10311
query52	76	78	73	73
query53	189	209	158	158
query54	249	218	218	218
query55	74	71	65	65
query56	251	236	235	235
query57	1639	1631	1550	1550
query58	294	278	264	264
query59	2147	2211	2013	2013
query60	288	257	233	233
query61	169	168	168	168
query62	391	346	292	292
query63	191	156	159	156
query64	2855	939	753	753
query65	3304	3242	3249	3242
query66	1759	426	302	302
query67	20143	20242	20078	20078
query68	2989	996	622	622
query69	376	292	238	238
query70	847	753	750	750
query71	289	231	204	204
query72	2746	2581	2501	2501
query73	518	523	297	297
query74	4595	4490	4344	4344
query75	2343	2285	1885	1885
query76	2316	995	605	605
query77	337	368	282	282
query78	9724	9649	9104	9104
query79	993	864	533	533
query80	1225	418	356	356
query81	574	320	270	270
query82	622	134	111	111
query83	296	188	186	186
query84	299	132	95	95
query85	852	424	359	359
query86	400	231	227	227
query87	2014	1942	1780	1780
query88	3896	2821	2804	2804
query89	328	281	240	240
query90	1873	169	167	167
query91	163	148	128	128
query92	95	89	87	87
query93	1027	980	574	574
query94	637	300	272	272
query95	578	379	312	312
query96	690	492	239	239
query97	2464	2423	2308	2308
query98	160	153	151	151
query99	770	754	655	655
Total cold run time: 235131 ms
Total hot run time: 150934 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 24.35 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 0d4e500f26bfba4918e20386eedb39784065699d, data reload: false

query1	0.01	0.00	0.01
query2	0.09	0.05	0.05
query3	0.25	0.09	0.08
query4	1.60	0.09	0.10
query5	0.23	0.24	0.24
query6	1.18	0.67	0.67
query7	0.04	0.00	0.00
query8	0.05	0.03	0.04
query9	0.40	0.33	0.36
query10	0.59	0.60	0.60
query11	0.20	0.14	0.14
query12	0.18	0.15	0.14
query13	0.49	0.49	0.49
query14	1.02	1.00	1.00
query15	0.63	0.61	0.61
query16	0.33	0.32	0.35
query17	1.13	1.13	1.14
query18	0.20	0.19	0.18
query19	2.13	2.00	2.04
query20	0.02	0.01	0.01
query21	15.51	0.19	0.14
query22	4.96	0.05	0.05
query23	16.15	0.31	0.13
query24	2.73	0.69	0.46
query25	0.09	0.07	0.06
query26	1.60	0.21	0.14
query27	0.04	0.06	0.04
query28	3.17	0.73	0.36
query29	12.50	4.11	3.28
query30	0.29	0.14	0.09
query31	2.77	0.56	0.33
query32	3.21	0.60	0.50
query33	3.21	3.18	3.28
query34	15.35	4.00	3.40
query35	3.34	3.32	3.33
query36	0.57	0.45	0.43
query37	0.10	0.07	0.06
query38	0.04	0.03	0.04
query39	0.04	0.03	0.02
query40	0.17	0.14	0.14
query41	0.08	0.04	0.02
query42	0.04	0.03	0.03
query43	0.04	0.04	0.03
Total cold run time: 96.77 s
Total hot run time: 24.35 s

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 100.00% (6/6) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor

FE Regression Coverage Report

Increment line coverage 83.33% (5/6) 🎉
Increment coverage report
Complete coverage report

@morningman

Copy link
Copy Markdown
Contributor Author

Steps to reproduce

One duplicate-key table, one IVM MV over it (a plain projection, so an append-only detail MV), one BE. The FE needs enable_feature_changelog = true and enable_table_stream = true in fe.conf (the regression pipelines set both).

#69772 already rejects an expired offset for a unique-key base table, whose internal stream is read MIN_DELTA. A duplicate-key table is read APPEND_ONLY, and its expired offset is clipped instead; that is the case below.

CREATE DATABASE IF NOT EXISTS ivm_repro;
USE ivm_repro;
-- Keep the queries on t answered by t itself; step 7 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,
    v BIGINT
)
DUPLICATE KEY(k)
DISTRIBUTED BY HASH(k) BUCKETS 1
PROPERTIES (
    "replication_num" = "1",
    "changelog.enable" = "true",
    "changelog.ttl_seconds" = "86400"
);
INSERT INTO t VALUES (1, 10);

-- 1. Build the MV. Its first refresh is a COMPLETE one and records the offset of the MV's stream on t.
CREATE MATERIALIZED VIEW mv
BUILD IMMEDIATE REFRESH INCREMENTAL ON MANUAL
DISTRIBUTED BY HASH(k) BUCKETS 1
PROPERTIES ("replication_num" = "1")
AS SELECT k, 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. A row written after the offset.
INSERT INTO t VALUES (2, 20);

-- 3. Let the offset, and the row with it, grow older than the TTL before the MV reads them. This stands in
--    for a refresh that comes more than a day after a 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 task 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;
SELECT * FROM mv ORDER BY k;

-- 5. A refresh that may fall back to COMPLETE; repeat the task query until the task has finished.
REFRESH MATERIALIZED VIEW mv AUTO;
SELECT Status, RefreshMode, IvmFallbackReason, ErrorMsg FROM tasks('type'='mv')
WHERE MvDatabaseName = 'ivm_repro' AND MvName = 'mv' ORDER BY CreateTime DESC LIMIT 1;

-- 6. Compare the MV with the table.
SELECT * FROM mv ORDER BY k;
SELECT * FROM t ORDER BY k;

-- 7. With transparent rewrite on, the same query on t is answered from mv.
SET enable_materialized_view_rewrite = true;
SELECT k, v FROM t ORDER BY k;
EXPLAIN SHAPE PLAN SELECT k, v FROM t ORDER BY k;

What it printed on a local shared-nothing cluster:

Step Without this PR (master 86d62ae00c9, the parent of this PR) With this PR (0d4e500f26b)
1. build task SUCCESS, COMPLETE SUCCESS, COMPLETE
4. strict refresh task SUCCESS; RefreshMode and IvmFallbackReason are \N, ErrorMsg is empty FAILED; RefreshMode is \N, IvmFallbackReason is CHANGELOG_BROKEN, ErrorMsg is IVM incremental refresh failed for mv=mv, reason=CHANGELOG_BROKEN, detail=org.apache.doris.nereids.exceptions.AnalysisException: Changelog offset has expired according to changelog.ttl_seconds: start_tso=..., cutoff_tso=...
4. SELECT * FROM mv one row: 1, 10 one row: 1, 10
5. AUTO refresh task SUCCESS; RefreshMode and IvmFallbackReason are \N, ErrorMsg is empty SUCCESS, COMPLETE; IvmFallbackReason is CHANGELOG_BROKEN, ErrorMsg is empty
6. SELECT * FROM mv one row: 1, 10 two rows: 1, 10 / 2, 20
6. SELECT * FROM t the two rows the two rows
7. the same query with rewrite on one row; the plan is a PhysicalOlapScan[mv] two rows; the plan is a PhysicalOlapScan[mv]

So without the fix the strict refresh reports success without the row written in step 2, and the AUTO refresh after it does not fall back either. No later incremental refresh brings the row back (the stream offset is now past it), and the query on t returns the MV's answer. Nothing in either task, its error or its fallback reason says that anything went wrong. With the fix the strict refresh fails with CHANGELOG_BROKEN and leaves the MV as it was, and the AUTO refresh rebuilds it with COMPLETE.

Variants, all in test_ivm_changelog_offset_expired_dup: an aggregate MV over the same table behaves as the detail MV here, without the fix and with it; a user stream on the table still reads from the first TSO the changelog retains; and once the TTL is back to a day, the refresh after the rebuild is incremental again.

@hello-stephen

Copy link
Copy Markdown
Contributor

FE Regression Coverage Report

Increment line coverage 83.33% (5/6) 🎉
Increment coverage report
Complete coverage report

@yujun777 yujun777 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.

LGTM

@morningman
morningman merged commit 468afdc into apache:master Oct 29, 2026
39 checks passed
morningman added a commit that referenced this pull request Oct 29, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants