Skip to content

perf(core): skip unchanged buckets on O3 incremental refresh - #7112

Merged
bluestreak01 merged 19 commits into
masterfrom
mv-o3-refresh-clustering
May 23, 2026
Merged

perf(core): skip unchanged buckets on O3 incremental refresh#7112
bluestreak01 merged 19 commits into
masterfrom
mv-o3-refresh-clustering

Conversation

@nwoolmer

@nwoolmer nwoolmer commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • When an out-of-order historical write into a mat view's base table lands
    far behind the current commit position, the incremental refresh on
    master scans every non-empty bucket between the O3 timestamp and "now",
    including pre-existing ones that no WAL transaction touched. The cursor
    filter is set to the full [minTs, maxTs] envelope and the iterator's
    step-group spans the gap. For a 512-symbol mat view with a 12-hour O3
    lag the refresh takes 160 ms instead of the ~2 ms the actual work
    requires.

  • This PR adds a cost-aware clustering pass in MatViewRefreshJob. Two
    rolling EMAs on MatViewState (avg commit latency in ns, avg scan
    latency per timestamp-unit) drive a gap-width threshold below which two
    adjacent cached intervals get merged. computePerClusterSteps then
    produces one iterator step per cluster (min(naturalStep, clusterWidthBuckets)) so a step-group never straddles two clusters --
    the existing gap-skip excises the gap cheaply, and each cluster gets
    the step that best fits its own width.

  • The iterator (SampleByIntervalIterator + FixedOffsetIntervalIterator

    • TimeZoneIntervalIterator) carries a parallel LongList stepPerInterval and swaps the active step on cluster transition. A new
      snapToCluster() hook aligns the iterator's hi to the next cluster's
      bucket-aligned lo, so a small per-cluster step doesn't walk the gap
      one bucket at a time.
  • materialized_views() gains three columns (refresh_avg_commit_nanos,
    refresh_avg_scan_nanos_per_ts_unit, refresh_gap_threshold_ts_units)
    for operator visibility into the cost model. REFRESH MATERIALIZED VIEW <name> STATS resets the EMAs to cold-start when the workload shape
    changes.

  • One new configuration key: cairo.mat.view.refresh.max.clusters
    (default 32) caps the number of clusters per refresh to prevent
    pathological many-disjoint-intervals workloads from emitting hundreds
    of tiny commits.

Benchmark numbers

MatViewO3RefreshBenchmark, 512 symbols, 1440-minute seed.

Original sweep (single-tick recent writes, wideBuckets=1):

rowsPerQuery o3LagMin Before After Delta
1,000,000 0 0.45 ms 0.40 ms baseline
1,000,000 30 6.4 ms 2.2 ms 2.9x faster
1,000,000 120 22 ms 1.8 ms 12x faster
1,000,000 720 160 ms 1.7 ms 94x faster
1,000 720 2.7 ms 1.6 ms similar

Mixed-width sweep (rowsPerQuery=1M, o3LagMin=720, varying the
number of consecutive recent ticks per iteration). This exposes the
scenario where a narrow far-back O3 cluster co-exists with a wide
recent cluster:

wideBuckets Master Global step-cap Per-cluster step
1 160 ms 1.79 ms 1.73 ms
10 160 ms 1.76 ms 1.58 ms
100 160 ms 1.03 ms 0.36 ms
1000 160 ms 5.33 ms 0.76 ms

The global step-cap variant pins the iterator step to the narrowest
cluster's width, so a 1000-bucket wide cluster paired with a point
narrow cluster pays 1000 factory.getCursor() calls. The per-cluster
step recovers that gap at K=1000 (-86%) and K=100 (-65%) while leaving
the K=1 case unchanged.

Tradeoffs

  • The previously warm fast path (no O3, default config) moves from
    0.45 ms to 0.40 ms; the difference is at the level of bench noise.
  • Two-cluster commit overhead is unchanged (one REPLACE_RANGE per
    cluster); the K=1 mixed case is ~1.7 ms regardless of step strategy
    because it is two point-interval commits.
  • RefreshContext gains four fields (naturalStep, stepPerInterval,
    refreshIntervals, approxBucketSize) so the OOM / too-many-rows
    retry paths can recompute the per-cluster step list without re-running
    findRefreshIntervals.

Test plan

  • 404 mat view tests pass (390 existing + 14 new).
  • Unit tests for the cost-model helpers:
    • MatViewRefreshJobClusterTest -- 23 tests covering empty/single
      intervals, adjacent merges, distant splits, maxClusters cap,
      overflow handling at Long.MAX_VALUE, 200-point-interval cap
      pressure, point intervals, malformed inputs, and the new
      computePerClusterSteps per-cluster cap semantics
    • MatViewStateEmaTest -- 16 tests covering cold-start, EMA
      convergence, zero/negative samples, outlier capping, pathological
      cold-start scan rates, STATS reset, overflow at Long.MAX_VALUE
  • Iterator transition tests:
    • FixedOffsetIntervalIteratorTest.testPerClusterStepSnapsAcrossGap --
      verifies the snap on cluster transition produces a fresh step-group
      anchored at the cluster boundary
    • FixedOffsetIntervalIteratorTest.testPerClusterStepThreeClusters --
      multi-cluster transition with mixed step widths
  • Integration tests in MatViewTest:
    • testRefreshIntervalsO3SplitsWideEnvelope -- regression test for
      the original wasted-recompute scenario
    • testRefreshIntervalsO3MergesNarrowGap -- auto-tune merge branch
    • testRefreshIntervalsO3SingleIntervalUnaffected -- guards against
      step-cap over-shrinking on a single dirty interval
    • testRefreshIntervalsO3PeriodMatView -- period mat view with
      clustering active
    • testRefreshMaterializedViewStatsResetsEma -- SQL-level smoke test
      for the new STATS form
  • All existing MatViewTest, MatViewStateTest, MatViewTelemetryTest,
    MatViewReloadOnRestartTest, CreateMatViewTest, MatViewFuzzTest
    tests pass unchanged.

nwoolmer and others added 2 commits May 18, 2026 21:22
When a historical (O3) write into a mat view's base table lands far
behind the current commit position, the cached refresh intervals get
combined into a single [getQuick(0), getQuick(size-1)] envelope inside
findRefreshIntervals. The resulting SAMPLE BY cursor then scans every
non-empty bucket between the O3 timestamp and "now" -- including
previously-refreshed buckets that no WAL transaction has touched.

This change adds a cost-aware clustering pass between WAL interval
loading and iterator construction:

  * MatViewState tracks two exponential moving averages: average commit
    latency (avgCommitNanos) and average per-timestamp-unit scan rate
    (avgScanNanosPerTsUnit). Samples come from the actual commitWithParams,
    commitMatView, and factory.getCursor calls inside insertAsSelect.
    Samples larger than 5x the current average are capped before folding
    (single-GC-pause protection); EMA smoothing factor is alpha = 1/8.

  * MatViewRefreshJob.clusterIntervals merges two adjacent cached
    intervals when the gap between them is smaller than the EMA-derived
    threshold (commitNanos / scanNanosPerTsUnit). A safety cap on the
    number of clusters per refresh prevents pathological scattered-O3
    workloads from emitting hundreds of tiny commits; configurable via
    cairo.mat.view.refresh.max.clusters (default 32).

  * MatViewRefreshJob.capStepByNarrowestInterval reduces the iterator
    step to the width of the narrowest cluster so that one step-group
    can never straddle two clusters. Without this cap, clustering alone
    is ineffective: even with disjoint intervals, a huge step-group
    intersects both and the cursor's range filter widens to the full
    span.

Three new columns on materialized_views() expose the live cost-model
state for debugging: refresh_avg_commit_nanos,
refresh_avg_scan_nanos_per_ts_unit, refresh_gap_threshold_ts_units.

REFRESH MATERIALIZED VIEW <name> STATS resets the EMA values to cold-
start, useful after a workload-shape change makes the historical
averages unrepresentative.

Benchmark (MatViewO3RefreshBenchmark, 512 symbols, 1440-min seed):
   o3LagMin=720, rowsPerQuery=1M:  159.6 ms -> 1.97 ms (81x)
   o3LagMin=120, rowsPerQuery=1M:   22.1 ms -> 1.87 ms (12x)
   o3LagMin=30,  rowsPerQuery=1M:    6.4 ms -> 2.10 ms (3.0x)
   o3LagMin=0,   rowsPerQuery=1M:    0.45 ms -> 0.40 ms (baseline)

The 1M curve is flat across O3 lags after the change -- refresh cost
scales with dirty work, not envelope width.

Tests: 390 existing MatView* tests pass unchanged. New tests:
  * MatViewRefreshJobClusterTest: 13 direct unit tests for the cost
    helpers (empty, single, adjacent merge, distant split, maxClusters
    cap, overflow guard, partitionCount edge cases).
  * MatViewTest.testRefreshIntervalsO3SplitsWideEnvelope: regression
    test for the original O3-envelope-widening scenario.
  * MatViewTest.testRefreshIntervalsO3MergesNarrowGap: companion test
    that exercises the auto-tune's merge branch.
  * MatViewTest.testRefreshIntervalsO3SingleIntervalUnaffected: guard
    against the step-cap over-shrinking with a lone cached interval.
  * MatViewTest.testRefreshIntervalsO3PeriodMatView: period mat view
    path with clustering active.
  * MatViewTest.testRefreshMaterializedViewStatsResetsEma: SQL-level
    smoke test for the new STATS form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Edge-case review surfaced three places where the cost-model arithmetic
could misbehave on pathological inputs:

  * MatViewState.recordCommitNanos / recordScanMetrics fold samples into
    an EMA via prev * (EMA_ALPHA_INV - 1). For prev values near
    Long.MAX_VALUE / 7 this multiplication overflows to a negative
    value, contaminating the EMA. Both fold paths now route through a
    shared foldEma helper that uses Math.multiplyExact / Math.addExact
    and falls back to a 50/50 blend on overflow -- keeps the average
    monotone in sign and lets subsequent samples pull it back to a
    realistic regime.

  * MatViewRefreshJob.clusterIntervals computed gap as (lo - prevHi).
    For intervals at the extreme ends of the long range the subtraction
    wraps and the gap appears negative, which would have triggered an
    incorrect merge. Now uses Math.subtractExact and treats overflow as
    "definitely large, do not merge".

  * MatViewRefreshJob.capStepByNarrowestInterval similarly computed
    width as (hi - lo) without overflow handling, and would have
    overflowed to negative for malformed (hi < lo) entries. Now uses
    subtractExact and skips malformed entries.

Adds two focused test classes:
  * MatViewRefreshJobClusterTest gains overflow + maxClusters + many-
    point-intervals scenarios.
  * MatViewStateEmaTest (new) covers EMA arithmetic at Long.MAX_VALUE,
    cold-start lock-in protection, outlier capping, zero-range scan
    samples, and refreshStats() reset behaviour.

407 mat view tests pass (was 390). Benchmark unchanged at o3LagMin=720
rowsPerQuery=1M: 2.4 ms median (vs 2.0 ms before -- within JIT noise).

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

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c82549c7-a2b1-4336-8532-4f5b1706e03f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mv-o3-refresh-clustering

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nwoolmer nwoolmer added Core Related to storage, data type, etc. Materialized View Performance Performance improvements labels May 18, 2026
@nwoolmer nwoolmer changed the title perf(mat-view): skip unchanged buckets on O3 incremental refresh perf(core): skip unchanged buckets on O3 incremental refresh May 18, 2026
nwoolmer and others added 8 commits May 18, 2026 23:02
Review fixes:
  * Move the gap-threshold javadoc to the correct method
    (getCommitGapThresholdTsUnits), fix the "microseconds of timestamp
    range" wording to "ts-units (us or ns matching the base table)".
  * Fix stale {@link MatViewState#getCommitGapThresholdMicros} reference
    in MatViewRefreshJob.findRefreshIntervals to the renamed method.
  * Rename setRefreshMetricsForTesting parameter scanNanosPerMicro to
    scanNanosPerTsUnit, recordScanMetrics parameter rangeMicros to
    rangeTsUnits; update both javadocs to match the unit-agnostic API.
  * Add @testonly annotation to setRefreshMetricsForTesting.
  * Reorder MatViewState members: COLD_START_GAP_THRESHOLD_TS_UNITS goes
    first among public static finals (C < M); setRefreshMetricsForTesting
    moves into the set* block; foldEma moves up next to the other static
    helpers; refreshFail comes before refreshStats.
  * Reorder MatViewRefreshJob.capStepByNarrowestInterval before
    clusterIntervals (Cap < Clu) to match the alphabetical convention.
  * Reorder PropServerConfiguration.matViewRefreshIntervalsUpdatePeriod
    before matViewRefreshMaxClusters (Intervals < Max).
  * Rename local booleans mergeable -> isMergeable in MatViewRefreshJob,
    statsReset -> isStatsReset in SqlCompilerImpl.

CI fix:
  * ViewsFunctionTest.testViewsConsistentWithMatViewsAndTablesCommands
    snapshotted the full materialized_views() schema. Adding three new
    EMA columns broke the assertion; the new columns also carry
    timing-dependent values that can't be hardcoded. Project only the
    deterministic columns in the query.

New tests:
  * MatViewTest.testRefreshIntervalsMaxClustersConfigCapsClusterCount
    proves the cairo.mat.view.refresh.max.clusters property flows from
    PropertyKey through to clusterIntervals.
  * MatViewTest.testRefreshMaterializedViewStatsRejectsGarbageTail
    covers `REFRESH ... STATS FROM ...` and `STATS INCREMENTAL` being
    rejected at parse time.
  * MatViewTest.testRefreshMaterializedViewUnknownActionMentionsStats
    asserts the unknown-action error mentions 'stats' so operators can
    discover the new form.
  * MatViewTest.testRefreshMaterializedViewStatsBusyView covers the
    tryLock-fails path returning a retryable SqlException.
  * MatViewTest.testRefreshMaterializedViewStatsResetsEma now also
    asserts the catalogue function surfaces the EMA values via SQL
    after a real refresh, catching column-order regressions in
    MatViewsFunctionFactory.
  * MatViewStateEmaTest.testEmaOuterMultiplierOverflowFallback exercises
    the multiplyExact catch branch in the outlier cap; the existing
    testEmaArithmeticDoesNotOverflowOnMaxValues now asserts the exact
    50/50 blend value rather than just non-zero.
  * MatViewRefreshJobClusterTest.testCapStepSkipsMalformedHiLessThanLo
    covers the widthTsUnits < 0 defensive continue.

Hardening:
  * MatViewStateEmaTest now constructs a real (empty) MatViewDefinition
    instead of passing null. Future maintainers adding a
    viewDefinition.getMatViewToken() call to any EMA method will fail
    loudly with a definition rather than NPE silently.
  * MatViewO3RefreshBenchmark throws if rowsDelta <= 0 so a future
    refresh-path regression that silently emits no rows reports as a
    failure rather than a phantom speedup.

423 mat view tests pass (was 407 before this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI snapshot tests lagged behind the mat view refresh changes on
this branch and failed across all griffin/other platforms:

- SqlParserTest#testRefreshMatView5 asserted the old parser error
  message. The STATS sentinel added in 63125db widened the
  message to mention 'stats' as a valid refresh keyword.
- ServerMainTest#testShowParameters compares the full set of
  configured properties. The new cairo.mat.view.refresh.max.clusters
  property (default 32, non-reloadable) was missing from the
  expected set.

Both tests now pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
clusterIntervals used to mutate the LongList it received in place, and
the caller passed in viewState.refreshIntervals -- the live reference
to the cached union of unprocessed WAL ranges. On a refresh failure
(or the TableReferenceOutOfDateException re-enqueue path that does
not invoke refreshFailState), the clustered list survived in memory
and was persisted on the next cache write or restart. Once the gap
information between merged intervals was lost it never came back: the
next refresh's union started from the already-clustered cache, and
subsequent retries kept widening the envelope.

Make the cache stay loss-free:

  * clusterIntervals takes an explicit destination LongList; it clears
    the destination, copies the source into it, then runs the existing
    merge algorithm on the destination. The source is never written.
  * MatViewRefreshJob holds a clusteredIntervals scratch field. The
    refresh callsite now rebinds the local refreshIntervals variable
    to that scratch after clustering, so the period and refresh-limit
    intersect/union helpers downstream also operate on the working
    copy. The viewState reference is observable but read-only from the
    clustering callsite onward.

Tests:

  * Update every MatViewRefreshJobClusterTest call to the new
    signature; assertions now check the destination list.
  * Add testClusterSourceIsNeverMutated to lock the source-read-only
    contract.
  * Add testClusterReusesDestinationListAcrossCalls to lock the
    clear-on-entry contract -- the production scratch is reused across
    refreshes and must not leak stale entries.

Verification: 22/22 MatViewRefreshJobClusterTest, 184/184 MatViewTest,
89/89 across the other mat-view test classes, 22/22 MatViewFuzzTest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cost-model accessors hid two regimes behind a Math.max(1, ...) clamp
that made dormant clustering look active in materialized_views():

  recordScanMetrics: A sub-resolution sample (scan finishes faster than
  one ts-unit of range) was floored to 0 by integer division, then
  pinned to 1 by the clamp. Subsequent EMA folding locked the rate
  orders of magnitude above the true scan cost, which in turn collapsed
  getCommitGapThresholdTsUnits below the cold-start default. Reject
  these samples up front so the cold-start guard keeps firing instead.

  getCommitGapThresholdTsUnits: When commit < scanPerTsUnit, the cost
  model says a fresh REPLACE_RANGE commit is cheaper than scanning a
  single ts-unit of gap, so gap-based merging should be off. The
  previous Math.max(1, 0) = 1 looked like "merge any gap below 1 ts
  unit" in the catalogue but was functionally indistinguishable from
  disabled, because IntervalUtils.unionInPlace guarantees adjacent
  intervals are at least one ts-unit apart and the condition
  gap < 1 is unreachable. Return the raw quotient (0 in that regime)
  so operators reading materialized_views() can tell clustering is
  dormant; clusterIntervals already treats a non-positive threshold
  as gap-merge disabled, only the maxClusters safety cap fires.

testRefreshMaterializedViewStatsResetsEma seeded its EMA values via a
single-row refresh, which is now correctly rejected as sub-resolution.
Acquire the refresh latch and seed scan metrics explicitly so the
STATS reset assertions still have something non-trivial to clear, and
drop the post-reset scan-EMA recovery check that depended on the
previous lenient recording path.

EMA edge-case tests updated for the new contract: 0 is the gap-merge
disabled sentinel, not 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-sample-ratio storage (sampleNanos / rangeTsUnits) rejected every
scan sample whose wall-clock duration was shorter than the timestamp
range it covered. On TIMESTAMP_NS bases the two quantities share the
same magnitude so the guard fired on every realistic refresh, leaving
avgScanNanosPerTsUnit at 0 and pinning the gap-merge threshold to the
cold-start sentinel for the lifetime of the view. The auto-tune the
clustering work introduced was dormant on ns workloads.

Switch to a two-EMA design: store wall-clock sample and ts-range width
as separate rolling averages, derive the per-ts-unit rate at read time
via commit * range / sample (saturating to MAX/2 on overflow). Every
positive sample folds; the catalogue exposes two natural-unit columns
(refresh_avg_scan_sample_nanos, refresh_avg_scan_range_ts_units)
instead of one dimensionally-weird scaled column.

Make the cold-start sentinel unit-aware. The previous constant was
interpreted as 2 s on us bases but 2 ms on ns bases -- a 1000x
asymmetry that created an abrupt threshold jump after the first
refresh on ns views. COLD_START_GAP_THRESHOLD_MICROS is now converted
via the base table's TimestampDriver, so both base types start with
the same wall-clock-equivalent merge budget.

Round half-up in foldEma. With prev = 1 the floor-divide form created
a fixed point: cap = 5 made (7 + capped)/8 always 0 or 1 and the EMA
could not grow regardless of sample size. The trap is narrow under
the two-EMA design but the one-line fix is strictly better, with a
sub-permille bias against typical EMA magnitudes.

Tighten testRefreshIntervalsMaxClustersConfigCapsClusterCount. The
existing assertions (intervals-count before / after) passed regardless
of the cap value -- both raw and capped runs cleared the cache. The
test now reads the mat-view writer txn before and after the refresh
and asserts the delta is 2 (matches the cap of 2). Without the cap
the same workload emits 5 commits, so a regression that ignores the
cap would fail loudly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
recordScanMetrics ran at the top of each interval-iterator entry, before
the rollback-and-retry check that fires when insertedRows > batchSize.
The retry path rescans the same data with a smaller step, so the same
ts-range gets sampled twice -- once on the wasted big-step pass and
again on the finer-step retry. The OOM and commit-failure paths in the
same retry loop have the same shape: iterations completed before the
throw had their samples folded into the EMA, then walWriter.rollback()
discarded the inserts.

Accumulate scan metrics into a pending (sampleNanos, rangeTsUnits)
pair across iterations of a single attempt. Flush into the EMA only
after a REPLACE_RANGE commit succeeds. Rollback paths (retry,
exception) restart the OUTER for-loop, which re-declares the locals
at zero -- no explicit clear needed.

The change makes the two scan EMAs dimensionally consistent with
avgCommitNanos, which has always folded one sample per commit. The
sample/range ratio is preserved between per-iteration and per-commit
aggregation, so steady-state threshold magnitude is unchanged. Cold
start lingers one fold longer (until the first successful commit),
which is the safer direction -- the cost model only sees data that
actually committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread core/src/main/java/io/questdb/cairo/mv/MatViewRefreshJob.java Outdated
nwoolmer and others added 6 commits May 19, 2026 12:35
Address kafka1991 PR review: capStepByNarrowestInterval pinned the
iterator step globally to the narrowest cluster's width, so a wide
recent cluster paired with a far-back narrow O3 cluster paid one
cursor open per capped step-group across the entire wide cluster --
e.g. a 1000-bucket wide cluster + a point-interval far-back cluster
walked the wide cluster in 1000 step-groups.

Switch to per-cluster stepping:

  * computePerClusterSteps(intervals, approxBucketSize, naturalStep,
    out) replaces capStepByNarrowestInterval. It fills out with one
    step per cluster: min(naturalStep, clusterWidthBuckets). A narrow
    cluster no longer constrains the step on wider siblings, and vice
    versa.

  * SampleByIntervalIterator carries a parallel LongList stepPerInterval
    on top of the existing intervals list. The new toTop(LongList)
    arms the per-cluster path; toTop(long) keeps the legacy single-step
    behaviour for unaffected tests. On cluster transition the inner
    next() loop swaps `step` to the new cluster's step.

  * snapToCluster(clusterLo) is a new abstract hook implemented by the
    two iterator subclasses. When the current step-group is entirely
    before the next cluster, the base class snaps the iterator's hi to
    the cluster boundary before re-entering next0(), so a small
    per-cluster step never walks the gap one bucket at a time. The
    FixedOffsetIntervalIterator snap is a sampler.round() on the
    cluster lo; the TimeZoneIntervalIterator snap converts to local
    time, applies adjustLoBoundary, then re-runs the shift-tracking
    state machine up to the snapped position.

  * The retry path now scales naturalStep down and recomputes the
    per-cluster step list rather than carrying a single intervalStep
    variable. RefreshContext gains naturalStep / stepPerInterval /
    refreshIntervals / approxBucketSize fields so the OOM and
    too-many-rows retry branches can recompute without re-running
    findRefreshIntervals.

Benchmark on (rowsPerQuery=1M, o3LagMin=720, NUM_SYMBOLS=512):

| wideBuckets | before | after  | delta |
|-------------|--------|--------|-------|
| 1           | 1.79ms | 1.73ms | flat (no cap to remove) |
| 10          | 1.76ms | 1.58ms | -10%  |
| 100         | 1.03ms | 0.36ms | -65%  |
| 1000        | 5.33ms | 0.76ms | -86%  |

Wide-only (o3LagMin=0) baselines are unchanged within noise across
all wideBuckets values, confirming the change is targeted at the
mixed-width path rather than affecting single-cluster refreshes.

Tests:

  * MatViewRefreshJobClusterTest: 11 cap tests migrated to the new
    computePerClusterSteps function. 10 clusterIntervals tests
    unchanged.
  * FixedOffsetIntervalIteratorTest: 2 new tests
    (testPerClusterStepSnapsAcrossGap, testPerClusterStepThreeClusters)
    pin down the cluster-transition + snap behaviour.
  * 397-test mat-view suite (MatViewTest 184, CreateMatViewTest 100,
    MatViewFuzzTest 22, etc.) passes unchanged.

MatViewO3RefreshBenchmark extended with a WIDE_RECENT_BUCKETS_SWEEP
dimension so future changes can be measured against the same
mixed-width scenario kafka1991 raised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
testRefreshIntervalsO3RandomFuzz drives 8-30 random batches of inserts
into a 1-minute mat view's base table. Each batch picks:

  * A signed lag in [-maxLagMinutes, +maxLagMinutes] from the current
    watermark; the sign is biased 70:30 toward negative so most batches
    land out-of-order. maxLagMinutes itself is random in [60, 2060]
    so individual runs cover both short and long-haul O3.
  * A width in [1, maxBatchWidth] consecutive minute rows in a single
    INSERT (one WAL txn = one cached refresh interval, so the width
    directly drives cluster widths -- a 1-row batch is a point cluster,
    a 30-row batch is a wide cluster).

Between batches it randomly issues an incremental refresh with
probability midDrainProb (also seeded), so the cached refresh interval
list accumulates different shapes across runs. A final incremental
refresh flushes any remaining intervals.

The test then asserts two invariants:

  * The view is not invalidated by the O3 traffic.
  * The view content matches a fresh "select ts, last(price), min(price),
    max(price) from base_price sample by 1m" of the base table. This
    is the strongest invariant on the per-cluster step path: if the
    cluster transition swap or the snapToCluster hook has any
    off-by-one or boundary bug, the mat view drifts from the ground
    truth and assertSqlCursors reports the bucket-level diff.

10 runs at varying seeds covered batches=8..28, maxBatchWidth=2..23,
maxLagMinutes=232..1700, midDrainProb=0.22..0.66 -- the failing seed
is logged by generateRandom so any future regression is reproducible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add two TimeZoneIntervalIteratorTest cases that exercise the
post-DST cluster boundary on the Europe/Berlin fall-back and
spring-forward transitions.

Each test sets up two single-minute intervals straddling the
transition with a 1h sampler and per-cluster step of 1. The
post-transition cluster forces snapToCluster() to walk
shiftLoIndex / shiftOffset past the fold or gap and re-derive
the UTC bucket boundaries from the new offset. A regression where
the shift state is not advanced would emit buckets one hour off
from the expected [03:00, 04:00) UTC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull in the latest client commits on the mv-o3-refresh-clustering
branch, including the QWP sender batch-cap clamp, IPv4 / GEOHASH
column support, binary payloads, zstd defaults, and a few config
fixes that landed in the client repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bluestreak01
bluestreak01 previously approved these changes May 20, 2026
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 268 / 275 (97.45%)

file detail

path covered line new line coverage
🔵 io/questdb/cairo/DefaultCairoConfiguration.java 0 1 00.00%
🔵 io/questdb/cairo/mv/MatViewState.java 51 57 89.47%
🔵 io/questdb/cairo/mv/SampleByIntervalIterator.java 9 9 100.00%
🔵 io/questdb/PropertyKey.java 1 1 100.00%
🔵 io/questdb/cairo/mv/FixedOffsetIntervalIterator.java 12 12 100.00%
🔵 io/questdb/griffin/SqlCompilerImpl.java 13 13 100.00%
🔵 io/questdb/cairo/CairoConfigurationWrapper.java 1 1 100.00%
🔵 io/questdb/griffin/SqlKeywords.java 6 6 100.00%
🔵 io/questdb/cairo/mv/TimeZoneIntervalIterator.java 47 47 100.00%
🔵 io/questdb/griffin/engine/functions/catalogue/MatViewsFunctionFactory.java 16 16 100.00%
🔵 io/questdb/PropServerConfiguration.java 2 2 100.00%
🔵 io/questdb/cairo/mv/MatViewRefreshJob.java 110 110 100.00%

@bluestreak01
bluestreak01 merged commit 48201a1 into master May 23, 2026
51 checks passed
@bluestreak01
bluestreak01 deleted the mv-o3-refresh-clustering branch May 23, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Core Related to storage, data type, etc. Materialized View Performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants