Skip to content

[fix](ivm) Fall back to COMPLETE when an IVM stream offset has expired - #69772

Merged
morningman merged 1 commit into
apache:masterfrom
morningman:changelog-monitor-t0
Oct 28, 2026
Merged

morningman merged 1 commit into
apache:masterfrom
morningman:changelog-monitor-t0

Conversation

@morningman

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary:

In short. Once a base table's changelog.ttl_seconds expires the offset of an IVM materialized view's internal stream, every refresh of that MV fails, including refreshes that are allowed to fall back to COMPLETE, until someone runs a manual COMPLETE refresh. This PR classifies that failure as CHANGELOG_BROKEN, so a refresh that may fall back rebuilds the MV with COMPLETE (the only attempt that resets the stream baselines) and a strict INCREMENTAL refresh fails with that reason.

Background. An IVM MV reads each unique-key (merge-on-write) base table through an internal table stream created with MIN_DELTA. Planning a MIN_DELTA read refuses an offset that is older than what the table still retains: ChangelogUtils.effectiveStartTso throws Changelog offset has expired according to changelog.ttl_seconds. The incremental refresh turns a failure into a fallback only when IvmFailureClassifier recognizes its message.

The problem, and what it cost.

  • The expired-offset message was not one the classifier knew, so IvmIncrRefreshManager.doRefresh rethrew it and the task failed even for REFRESH ... AUTO, INCREMENTAL FALLBACK, or the scheduled refresh of a REFRESH INCREMENTAL FALLBACK MV.
  • A failed refresh does not move the offset, so while the TTL stays as it is every later refresh fails the same way, and the MV keeps serving old rows.
  • The delta is not the only read of that offset. When some MV partitions need a rebuild (for example after a TRUNCATE of a base partition), the incremental attempt first rebuilds them with a partition refresh, which reads the tables the MV does not partition by through their streams at the offsets (StreamReadMode.SNAPSHOT). With such an offset expired that rebuild failed the same way, before the delta ran.
  • IvmFailureReason.CHANGELOG_BROKEN had no producer in production code, although MTMVTask.handleIvmFallbackResult already sends it straight to COMPLETE; the comment and log of that branch described it as "previous run incomplete".

How this PR fixes it.

  • IvmFailureClassifier maps a failure carrying ChangelogUtils.CHANGELOG_OFFSET_EXPIRED to CHANGELOG_BROKEN (matching the constant the read throws with, not a copy of it).
  • MTMVTask.executeIvmAttempt answers that failure of the rebuild before the delta through handleIvmFallbackResult, the way the delta's failure is answered; any other rebuild failure still fails the attempt.
  • The CHANGELOG_BROKEN branch of handleIvmFallbackResult now explains why it goes directly to COMPLETE (a partition refresh still reads the streams at their offsets) and logs the detail with the offset and cutoff TSO.

Results (local shared-nothing cluster):

Refresh after the offset expired Before After
REFRESH ... INCREMENTAL (strict) FAILED, IvmFallbackReason empty FAILED, IvmFallbackReason = CHANGELOG_BROKEN, error says reason=CHANGELOG_BROKEN with both TSOs
REFRESH ... AUTO FAILED, MV keeps the old rows SUCCESS through COMPLETE, IvmFallbackReason = CHANGELOG_BROKEN
the next REFRESH ... AUTO fails again while the TTL is unchanged SUCCESS, incremental
REFRESH ... AUTO that first rebuilds a dirty MV partition while a joined table's offset has expired FAILED SUCCESS through COMPLETE, IvmFallbackReason = CHANGELOG_BROKEN

Classes, and how they call each other.

  • MTMVTask.run → executeIvmAttempt → (dirty partitions) executePartitionBasedRefresh → on an expired offset handleIvmFallbackResult(CHANGELOG_BROKEN)
  • executeIvmAttempt → executeSingleIvmAttempt → IvmIncrRefreshManager.doRefresh → IvmFailureClassifier.classifyExecutionFailure → IvmIncrRefreshResult.fallback(CHANGELOG_BROKEN) → handleIvmFallbackResult
  • handleIvmFallbackResult: strict request → JobException with the reason; otherwise FALLBACK_TO_COMPLETE → executeCompleteAttempt, which resets the stream baselines.

Not changed: the planner's TTL check itself, the PARTITIONS attempt (it reads a changed table at an old offset only after a partition refresh that failed part-way: a plan covering every partition reads the joined tables with RESET), and the persisted task fields.

Release note

Fix an IVM materialized view refresh failing on every run after a base 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 (new, nonConcurrent), covering the delta and the rebuild before it. Local shared-nothing cluster: the delta case was first run on the base commit (strict and AUTO both FAILED); the rebuild case was reproduced by hand on a build with only the classifier change (AUTO FAILED on the rebuild). With the whole fix the suite passes twice in a row against the generated .out. Not run on a cloud cluster.
    • Unit Test: IvmIncrRefreshManagerTest (new case), MTMVTaskTest (two new cases, strict and fallback), IvmFailureReasonTest, ChangelogUtilsTest: 94 tests pass; each new case fails when its half of the fix is removed.
  • Behavior changed: Yes. A refresh that allows fallback recovers with COMPLETE instead of failing; a strict INCREMENTAL refresh reports CHANGELOG_BROKEN.
  • Does this need documentation: No

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary:

An IVM materialized view reads each unique-key (merge-on-write) base table
through an internal table stream created with MIN_DELTA. Planning a MIN_DELTA
read refuses to start from an offset that the base table's
`changelog.ttl_seconds` has expired: `ChangelogUtils.effectiveStartTso` throws
"Changelog offset has expired according to changelog.ttl_seconds".

The incremental refresh turns a failure into a fallback only when
`IvmFailureClassifier` recognizes its message, and this message was not one of
them. `IvmIncrRefreshManager.doRefresh` rethrew it, so the task failed even when
the refresh allowed a fallback (`REFRESH ... AUTO`, `INCREMENTAL FALLBACK`, the
scheduled refresh of a `REFRESH INCREMENTAL FALLBACK` MV). The offset does not
move on a failed refresh, so while the TTL stays as it is every later refresh
fails the same way, until someone runs a manual COMPLETE refresh.
`IvmFailureReason.CHANGELOG_BROKEN` had no producer in production code, although
`MTMVTask.handleIvmFallbackResult` already sends it straight to COMPLETE.

The delta is not the only read of that offset. When some MV partitions need a
rebuild (for example after a TRUNCATE of a base partition), the incremental
attempt first rebuilds them with a partition refresh, and that refresh reads
the tables the MV does not partition by through their streams at the offsets
(`StreamReadMode.SNAPSHOT`). With such an offset expired, the rebuild failed
with the same message before the delta ran, and that failure went straight out
of the attempt as well.

How to reproduce: create a base table with the changelog enabled and a large
TTL, build an IVM MV on it, then set `changelog.ttl_seconds` to 1, wait two
seconds, insert a row and run `REFRESH MATERIALIZED VIEW mv AUTO`.

| Refresh after the offset expired | Before | After |
| --- | --- | --- |
| `REFRESH ... INCREMENTAL` (strict) | FAILED, `IvmFallbackReason` empty | FAILED, `IvmFallbackReason = CHANGELOG_BROKEN`, the error says `reason=CHANGELOG_BROKEN` and keeps the offset and the cutoff TSO |
| `REFRESH ... AUTO` | FAILED, the MV keeps the old rows | SUCCESS through COMPLETE, `IvmFallbackReason = CHANGELOG_BROKEN` |
| the next `REFRESH ... AUTO` | fails again while the TTL is unchanged | SUCCESS, incremental |
| `REFRESH ... AUTO` that first rebuilds a dirty MV partition while a joined table's offset has expired | FAILED | SUCCESS through COMPLETE, `IvmFallbackReason = CHANGELOG_BROKEN` (a strict request fails with that reason) |

The fix classifies that message as `CHANGELOG_BROKEN`, matching the constant
`ChangelogUtils` throws with rather than a copy of it, and the rebuild before
the delta answers that failure through `handleIvmFallbackResult` the way the
delta does; any other rebuild failure still fails the attempt. COMPLETE is the only
attempt that resets the stream baselines, which is why the reason goes there
directly instead of trying a partition refresh first: a partition refresh still
reads the streams at their offsets. The comment and the log of that branch said
"previous run incomplete", which is not what the reason means; they now describe
the expired changelog, and the log carries the detail with both TSOs.

### Release note

Fix an IVM materialized view refresh failing on every run after a base
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` (new,
      nonConcurrent), covering the delta and the rebuild before it. Run on a
      local shared-nothing cluster. The delta case was first run on the base
      commit, where strict and AUTO both FAILED; the rebuild case was reproduced
      by hand on a build that had only the classifier change (AUTO FAILED on the
      rebuild). With the whole fix the suite passes, twice in a row against the
      generated `.out`. Not run on a cloud cluster.
    - Unit Test: `IvmIncrRefreshManagerTest` (new case for the expired offset),
      `MTMVTaskTest` (two new cases for the rebuild, strict and fallback),
      `IvmFailureReasonTest`, `ChangelogUtilsTest`: 94 tests pass.
- Behavior changed: Yes. A refresh that allows fallback recovers with COMPLETE
  instead of failing; a strict INCREMENTAL refresh reports `CHANGELOG_BROKEN`.
- Does this need documentation: No

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

Copy link
Copy Markdown
Contributor Author

/review

@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@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 cc473a3 against the supplied authoritative diff. No new substantiated findings and no existing inline blockers. Review converged after one round: the production, test, and separate recovery-risk reviewers each returned NO_NEW_VALUABLE_FINDINGS; the main review verified and closed every candidate and risk focus.

The inspected flow is: expired MIN_DELTA read in the delta or pre-delta partition rebuild -> preserved root-cause marker -> CHANGELOG_BROKEN -> strict failure, or skip PARTITIONS and rebuild with COMPLETE when fallback is allowed.

Critical checkpoint conclusions:

  1. Goal and evidence: the classifier and pre-delta handler cover both intended expired-offset failure sites. Added unit cases and regression scenarios check strict refusal, AUTO recovery, and a subsequent incremental refresh; these were inspected, not executed.
  2. Scope and reuse: the production change is focused and reuses the existing expiration constant and fallback handler.
  3. Concurrency and locks: no new threads, shared mutable state, lock acquisitions, or lock-order changes. Existing per-MV job serialization, cancellation checks, and epoch ceilings remain intact.
  4. Lifecycle: execution-context cleanup and rebuilt-partition accounting retain their finally blocks; no new resource or static-initialization lifetime.
  5. Configuration: none added. The existing table TTL is read during planning; tests shorten and restore it.
  6. Compatibility: no persisted field, enum value, function interface, or storage-format change; CHANGELOG_BROKEN already exists.
  7. Thrift/protobuf: no changes.
  8. Parallel paths: checked delta, pre-delta rebuild, COMPLETE RESET/SNAPSHOT, manual strict/fallback, scheduled policy resolution, cloud and append-only branches. Standalone PARTITIONS retains its explicitly unchanged prior behavior.
  9. Conditions and errors: only recognized expiration is converted by the new catch; unrelated rebuild errors propagate. Strict requests are rejected before recovery, with reason and original TSO detail.
  10. Test coverage: focused unit and end-to-end coverage matches the intended fix. Scheduled triggers, append-only sources, concurrent invalidation, partial commits, and failover were examined statically rather than exercised by the new suite.
  11. Test results: all 15 expected-output sections match the SQL changes and task reporting. Ordered row checks and normalized task fields avoid variable TSO output. Output generation was not rerun.
  12. Observability: the existing task fallback-reason field and failure metrics remain; the recovery warning now includes the original detail with MV/task identifiers.
  13. Persistence and failover: existing durable rebuild requirements precede stream reset, and task-result replay preserves newer invalidations. No new journal or replay path.
  14. Writes and atomicity: COMPLETE includes the full partition scope, discards held older-image records, and records completed groups only. No new data-loss, double-application, or false-success path was substantiated.
  15. FE/BE propagation: no new transmitted variables or protocol fields.
  16. Performance and other concerns: marker matching is limited to an exception path; no new hot-path work or unbounded retry. No BE memory ownership, visible-version, or delete-bitmap logic changes. No additional user focus was supplied.

Validation limits: static inspection only, as required by this review contract. No builds, tests, benchmarks, cluster execution, or runtime fault injection were run. No unresolved PR-specific candidates remain.

Review converged after 1 round(s).

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

add an opt

// offsets, so an offset changelog.ttl_seconds has expired stops it the way it stops the delta
// below -- and it is the same failure of this attempt, answered the same way.
String detail = Util.getRootCauseMessage(e);
if (IvmFailureClassifier.classifyExecutionFailure(detail)

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.

This check can state the invariant instead of enumerating the reason, and pass the reason through:

Optional<IvmFailureReason> reason = IvmFailureClassifier.classifyExecutionFailure(detail)
        .filter(IvmFailureReason::requiresCompleteRefresh);
if (reason.isEmpty()) {
    throw e;
}
return handleIvmFallbackResult(IvmIncrRefreshResult.fallback(reason.get(), detail), request);

requiresCompleteRefresh() is exactly the set whose only repair is COMPLETE, which is what makes it valid to turn a rebuild failure into a fallback from inside the rebuild: there is no meaningful PARTITIONS fallback here (the rebuild already is the partition refresh) and the delta retry machinery (MV_PARTITION_NOT_FOUND <-> sync + retry) does not exist on this path. Behaviour is unchanged today, since the classifier can only produce CHANGELOG_BROKEN here; the difference is that the code says why the conversion is safe rather than which reason happened to be reachable, and the synthesized result carries its real reason instead of a hard-coded one.

@@ -1184,11 +1196,12 @@ private AttemptResultType handleIvmFallbackResult(IvmIncrRefreshResult ivmResult
+ ", detail=" + ivmResult.getDetailMessage());
}
if (ivmResult.getFailureReason() == IvmFailureReason.CHANGELOG_BROKEN) {

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.

This branch is redundant now: requiresCompleteRefresh() already returns true for CHANGELOG_BROKEN (IvmFailureReason.java:41-47), and the branch below returns the same FALLBACK_TO_COMPLETE while its log already prints reason={} and detail={}. The only difference is the log wording, so I would drop this branch entirely and keep the single requiresCompleteRefresh() branch. The explanation of why no partition attempt is tried before COMPLETE (a partition refresh still reads the streams at their offsets) still reads well as a comment on that generic branch, or next to the classifier entry that produces the reason. If the catch above is also filtered on requiresCompleteRefresh() (see the other comment), nothing remains here that is specific to CHANGELOG_BROKEN.

@yujun777
yujun777 self-requested a review October 28, 2026 13:12
@hello-stephen

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

------ Round 1 ----------------------------------
============================================
q1	17605	5162	5043	5043
q2	2107	336	267	267
q3	10219	1401	810	810
q4	4681	461	335	335
q5	7440	785	529	529
q6	176	179	153	153
q7	735	823	598	598
q8	9269	1363	1094	1094
q9	5503	4231	4170	4170
q10	6821	1287	1019	1019
q11	440	242	231	231
q12	637	399	281	281
q13	18109	3631	3068	3068
q14	278	274	251	251
q15	q16	734	708	673	673
q17	1240	764	584	584
q18	6990	6200	6121	6121
q19	1254	1097	601	601
q20	382	338	227	227
q21	5210	2124	2283	2124
q22	349	293	253	253
Total cold run time: 100179 ms
Total hot run time: 28432 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	5583	5605	5607	5605
q2	680	581	514	514
q3	5172	5372	4896	4896
q4	1987	2135	1539	1539
q5	5281	5113	5582	5113
q6	273	214	158	158
q7	2326	2096	1885	1885
q8	3046	2863	2758	2758
q9	8034	8098	8132	8098
q10	3741	3638	3240	3240
q11	616	440	404	404
q12	696	675	512	512
q13	3537	3961	3264	3264
q14	305	303	290	290
q15	q16	690	697	649	649
q17	8268	7584	7078	7078
q18	12880	12176	12784	12176
q19	929	806	811	806
q20	2119	2120	1928	1928
q21	5581	4601	4656	4601
q22	496	471	416	416
Total cold run time: 72240 ms
Total hot run time: 65930 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 151080 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 cc473a32f04fb170c4fa55702c02cdc1f9dc53f8, data reload: false

query5	4329	593	422	422
query6	429	208	201	201
query7	4806	434	229	229
query8	310	164	147	147
query9	8795	3256	3293	3256
query10	459	305	259	259
query11	5995	3854	3547	3547
query12	148	94	91	91
query13	1252	454	328	328
query14	6146	3917	3633	3633
query14_1	3403	3403	3392	3392
query15	213	210	184	184
query16	961	439	445	439
query17	877	652	568	568
query18	2427	436	311	311
query19	204	172	140	140
query20	91	105	84	84
query21	215	130	107	107
query22	12895	12899	12870	12870
query23	13495	12965	12422	12422
query23_1	12480	12582	12439	12439
query24	7368	842	472	472
query24_1	486	489	486	486
query25	542	420	363	363
query26	1257	258	144	144
query27	2747	449	283	283
query28	4597	2088	2070	2070
query29	1567	622	456	456
query30	298	224	181	181
query31	1008	936	821	821
query32	156	101	92	92
query33	535	307	243	243
query34	893	863	507	507
query35	740	748	642	642
query36	833	825	722	722
query37	127	100	88	88
query38	1763	1737	1659	1659
query39	785	773	744	744
query39_1	745	720	740	720
query40	219	121	102	102
query41	71	81	68	68
query42	89	85	85	85
query43	348	358	309	309
query44	1380	709	706	706
query45	180	175	157	157
query46	835	972	559	559
query47	3025	2964	2705	2705
query48	310	311	219	219
query49	582	396	287	287
query50	709	282	203	203
query51	10339	10531	10327	10327
query52	78	78	68	68
query53	186	205	157	157
query54	235	196	174	174
query55	68	67	67	67
query56	237	218	225	218
query57	1653	1603	1549	1549
query58	287	271	258	258
query59	2160	2137	1957	1957
query60	281	243	227	227
query61	149	166	145	145
query62	392	328	279	279
query63	192	157	159	157
query64	2736	924	795	795
query65	3319	3260	3265	3260
query66	1776	422	316	316
query67	20466	20112	19919	19919
query68	3031	1061	623	623
query69	393	314	252	252
query70	853	774	778	774
query71	308	231	216	216
query72	2702	2594	2352	2352
query73	521	530	306	306
query74	4630	4479	4328	4328
query75	2328	2306	1898	1898
query76	2317	1017	602	602
query77	341	376	275	275
query78	9898	9759	9237	9237
query79	988	911	537	537
query80	1230	421	345	345
query81	579	316	274	274
query82	758	133	105	105
query83	274	208	179	179
query84	309	126	101	101
query85	1100	427	380	380
query86	372	240	233	233
query87	2002	1948	1788	1788
query88	3865	2806	2800	2800
query89	333	281	244	244
query90	1789	171	167	167
query91	164	151	125	125
query92	92	91	81	81
query93	1018	1012	575	575
query94	566	307	273	273
query95	564	346	307	307
query96	698	489	240	240
query97	2426	2463	2321	2321
query98	162	156	160	156
query99	769	768	639	639
Total cold run time: 235659 ms
Total hot run time: 151080 ms

@hello-stephen

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

query1	0.01	0.01	0.00
query2	0.09	0.05	0.05
query3	0.26	0.09	0.08
query4	1.61	0.11	0.09
query5	0.23	0.24	0.24
query6	1.16	0.68	0.68
query7	0.03	0.01	0.00
query8	0.05	0.04	0.04
query9	0.40	0.34	0.34
query10	0.59	0.58	0.57
query11	0.20	0.14	0.15
query12	0.18	0.14	0.14
query13	0.50	0.50	0.48
query14	1.04	1.02	0.99
query15	0.64	0.62	0.61
query16	0.34	0.34	0.36
query17	1.14	1.15	1.16
query18	0.22	0.20	0.20
query19	2.08	1.99	1.91
query20	0.02	0.01	0.01
query21	15.51	0.19	0.14
query22	4.93	0.05	0.05
query23	16.26	0.33	0.13
query24	2.63	0.67	0.46
query25	0.13	0.07	0.08
query26	1.19	0.22	0.15
query27	0.06	0.02	0.06
query28	3.66	0.79	0.34
query29	12.48	4.11	3.31
query30	0.30	0.14	0.09
query31	2.76	0.55	0.33
query32	3.22	0.59	0.51
query33	3.12	3.22	3.14
query34	15.48	3.95	3.37
query35	3.35	3.33	3.35
query36	0.57	0.45	0.44
query37	0.10	0.06	0.06
query38	0.04	0.03	0.03
query39	0.04	0.03	0.02
query40	0.18	0.15	0.14
query41	0.09	0.03	0.03
query42	0.04	0.03	0.03
query43	0.04	0.04	0.03
Total cold run time: 96.97 s
Total hot run time: 24.28 s

@morningman
morningman merged commit 86d62ae into apache:master Oct 28, 2026
39 of 40 checks passed
morningman added a commit that referenced this pull request Oct 29, 2026
…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.
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.

3 participants