Skip to content

feat(query-orchestrator): honour refreshKeyRenewalThreshold on locally evaluated refresh keys - #11720

Open
ovr wants to merge 1 commit into
masterfrom
refresh-key-renewal-threshold-comment
Open

feat(query-orchestrator): honour refreshKeyRenewalThreshold on locally evaluated refresh keys#11720
ovr wants to merge 1 commit into
masterfrom
refresh-key-renewal-threshold-comment

Conversation

@ovr

@ovr ovr commented Sep 1, 2026

Copy link
Copy Markdown
Member

Check List

  • Tests have been run in packages where changes have been made if available
  • Linter has been run for changed code
  • Tests for the changes have been added if not covered yet
  • Docs have been added / updated if required

Description of Changes Made

CUBEJS_REFRESH_KEY_LOCAL_TIME used to disable itself whenever queryCacheOptions.refreshKeyRenewalThreshold was set, since the threshold caches the refresh key query result and that cache is also what bounds how often an every-based key can advance — a locally computed key has no cache entry to age out, so it would have advanced on every interval boundary and multiplied pre-aggregation rebuilds. This reproduces the bound with arithmetic instead: the new snapToRenewalThreshold samples the clock at the threshold granularity and the key is evaluated at that instant, so the two options compose and a deployment with a threshold keeps local evaluation (advancing at most once per window, with no query). Snapping keeps the value inside the key's own series — 144 really is a 10 minute key's value at the start of day two — and every instance samples the same instant, where the SQL path's phase depended on when each cache entry happened to be written. RefreshScheduler consequently stops warming interval keys under a threshold, since there is no longer a cache entry worth warming. Unit tests cover the snapping helper, both QueryCache paths and the scheduler, and the env var reference is updated.

`CUBEJS_REFRESH_KEY_LOCAL_TIME` used to switch itself off whenever
`queryCacheOptions.refreshKeyRenewalThreshold` was set, because the threshold caches
the refresh key query result and that cache is also what bounds how often an
`every`-based key can advance — a locally computed key has no cache entry to age out,
so it would have advanced on every interval boundary and multiplied pre-aggregation
rebuilds.

Reproduce the bound with arithmetic instead: sample the clock at the threshold
granularity (`snapToRenewalThreshold`) and evaluate the key at that instant. The two
options now compose, and a deployment that sets a threshold keeps local evaluation.

| | before | after |
| --- | --- | --- |
| threshold unset | local, no query | local, no query |
| threshold set | falls back to SQL per key | local, advances once per window |

Snapping keeps the value inside the key's own series — `144` really is a 10 minute
key's value at the start of day two — and every instance samples the same instant,
where the SQL path's phase depended on when each cache entry happened to be written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ovr
ovr requested review from a team and keydunov as code owners September 1, 2026 18:56
@mintlify

mintlify Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
cubed3 🟢 Ready View Preview Sep 1, 2026, 6:57 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions github-actions Bot added the javascript Pull requests that update Javascript code label Sep 1, 2026
@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 2m 38s —— View job


Review: approach is sound — 0 blocking, 1 medium (synchronized refresh boundaries), 3 low

Full review — expand for details

Todo

  • Gather context (diff, changed files, call sites)
  • Review snapToRenewalThreshold semantics & edge cases
  • Review QueryCache integration
  • Review RefreshScheduler behaviour change
  • Review tests & docs
  • Post findings

Summary

The core idea — reproduce the cache-derived rate bound arithmetically instead of vetoing local evaluation — is correct and a genuine improvement over the TODO it replaces. I verified the pieces that could silently be wrong:

  • Units line up. refreshKeyRenewalThreshold is seconds everywhere (optionsValidate.ts:128, QueryCache.ts:526); snapToRenewalThreshold multiplies by 1000 before flooring and evaluateLocalRefreshKey takes ms. ✅
  • The "don't snap to the per-key threshold" comment is accurate. BaseQuery.refreshKeyRenewalThresholdForInterval returns min(max(round(interval/10), 1), 300) — a fraction of the interval — so snapping to it would indeed only delay the boundary without changing the rate. It also matches cacheRefreshKeyResult, where the global option already takes precedence over the per-key one. ✅
  • threshold = 0 is a no-op in both paths. Joi permits 0, snapToRenewalThreshold returns nowMs unchanged, and cacheRefreshKeyResult's || chain already treated 0 as unset. Consistent. ✅
  • Monotonicity holds — flooring a monotonic clock is monotonic, and the test asserts it.
  • T < interval is harmless, T > interval gives the intended once-per-window advance, and snapping keeps the value inside the key's own series (the 144 case) rather than renumbering it — that property is what makes turning the threshold on/off not trigger a mass rebuild.

Findings below, none blocking.

Medium — refresh keys now all flip at the same instant

snapToRenewalThreshold aligns to the Unix epoch, so with refreshKeyRenewalThreshold: 86400 every every-based key across every cube, tenant and timezone advances at exactly 00:00 UTC. The SQL path bounded the rate identically but phased each key by when its cache entry was written, spreading rebuilds out. Cross-instance determinism is a real win; the burst is the cost. A per-key phase offset seeded from QueryCache.refreshKeyIdentity would buy both — see the inline comment on utils.ts.

Low

  1. Stale comment. packages/cubejs-server-core/src/core/RefreshScheduler.ts:363 still reads "and so do interval keys whenever the cache declines to evaluate them locally", which was written for the threshold veto this PR removes. It's not false — localRefreshKeyResult still declines on an invalid/absent descriptor — but the case it was describing is gone, so it now points at nothing a reader can find.
  2. Warming gap widens slightly. isLocalRefreshKeyActive() is a global flag, while localRefreshKeyResult also requires a valid per-key descriptor. Where those disagree (an every key that yields no descriptor), the scheduler skips warming but the cache still falls back to SQL, so the key query moves onto the user query path. Pre-existing for the threshold-unset case; this PR extends it to threshold-set deployments too. Probably fine given isValidLocalRefreshKey should hold for any real every key — flagging so it's a deliberate call rather than an accident.
  3. Test reads the real clock after the fact — inline comment on QueryCache.abstract.ts:644. Once-a-day flake at the UTC boundary; the sibling test you added in PreAggregations.test.ts already mocks Date.now and reads better for it.

Nits (no action needed)

  • <number>thresholdSeconds appears twice because Number.isFinite doesn't narrow number | undefined. typeof thresholdSeconds !== 'number' || !Number.isFinite(...) would narrow and drop both casts.
  • Infinity falls through the isFinite guard to "no snapping", which reads as the opposite of what an infinite threshold means. Unreachable through Joi (.integer() rejects it) and the test pins the current behaviour, so it's only a semantic curiosity.

Tests & docs

Coverage is good: the helper is tested directly (boundaries, monotonicity, the every-key series, and a cron key with a dayOffset), and both QueryCache paths plus the scheduler are updated rather than deleted. The cron test in particular is the one I'd have asked for. Docs correctly drop the old "so do all refresh keys if you have set…" carve-out; adding a line that thresholds align to UTC boundaries would round it out.

Not verified

I could not run yarn unit or yarn lintnode_modules is absent in this checkout and installing the monorepo wasn't warranted for a review. All findings above come from reading the code and its call sites, not from a test run.

• branch [`refresh-key-renewal-threshold-comment`](https://github.com/cube-js/cube/tree/refresh-key-renewal-threshold-comment)

Comment on lines +62 to +70
export function snapToRenewalThreshold(nowMs: number, thresholdSeconds?: number): number {
if (!Number.isFinite(thresholdSeconds) || <number>thresholdSeconds <= 0) {
return nowMs;
}

const thresholdMs = <number>thresholdSeconds * 1000;

return Math.floor(nowMs / thresholdMs) * thresholdMs;
}

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.

Synchronized boundaries replace staggered ones — worth acknowledging the ops trade-off.

Math.floor(nowMs / thresholdMs) * thresholdMs aligns to the Unix epoch, so with the common refreshKeyRenewalThreshold: 86400 every every-based refresh key, in every cube, tenant and timezone, flips at exactly 00:00 UTC. The SQL path it replaces bounded the rate the same way but phased each key by whenever its cache entry happened to be written, which spread the invalidations out.

The PR description frames the cross-instance determinism as a win, and it is — but the flip side is that all the pre-aggregation rebuilds a threshold was meant to throttle now land in one burst. On a deployment with many pre-aggregations that burst is what the queue sees at midnight.

You can keep determinism and the staggering by phasing per key rather than globally, e.g.:

export function snapToRenewalThreshold(nowMs: number, thresholdSeconds?: number, phaseSeed = 0): number {
  ...
  const offset = phaseSeed % thresholdMs;
  return Math.floor((nowMs - offset) / thresholdMs) * thresholdMs + offset;
}

with phaseSeed derived from the refresh key identity hash (QueryCache.refreshKeyIdentity), which is stable across instances and restarts. If you'd rather not, a sentence in the env-var docs warning that thresholds now align to UTC boundaries would set expectations.

Fix this →

Comment on lines +642 to 646
expect(executed).toBe(0);
expect(result).toEqual([{
refresh_key: String(Math.floor(Math.floor(Date.now() / 1000 / day) * day / descriptor.interval)),
}]);
});

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.

Nit: this re-reads the real Date.now() after loadRefreshKey ran, so the assertion is a coin flip if the clock crosses the daily boundary in between — a once-a-day flake at 00:00 UTC. The sibling test you added in PreAggregations.test.ts mocks Date.now; doing the same here would make the expected value a literal instead of a re-derivation of the implementation's own formula, which is also a stronger test.

(The existing 10-minute-interval assertions at lines 604/708 have the same shape, so this is consistent with the file — just noting it since the fix is cheap on a new line.)

@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.03%. Comparing base (4029495) to head (e97da3a).
⚠️ Report is 3 commits behind head on master.

❗ There is a different number of reports uploaded between BASE (4029495) and HEAD (e97da3a). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (4029495) HEAD (e97da3a)
cubesql 1 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11720       +/-   ##
===========================================
- Coverage   79.93%   59.03%   -20.91%     
===========================================
  Files         498      237      -261     
  Lines      104742    19012    -85730     
  Branches     3857     3861        +4     
===========================================
- Hits        83727    11223    -72504     
+ Misses      20454     7230    -13224     
+ Partials      561      559        -2     
Flag Coverage Δ
cube-backend 59.03% <100.00%> (+0.05%) ⬆️
cubesql ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

javascript Pull requests that update Javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant