Skip to content

feat(query-orchestrator): evaluate interval refresh keys from local time - #11614

Open
ovr wants to merge 2 commits into
masterfrom
refresh-key-local-time-flag
Open

feat(query-orchestrator): evaluate interval refresh keys from local time#11614
ovr wants to merge 2 commits into
masterfrom
refresh-key-local-time-flag

Conversation

@ovr

@ovr ovr commented Aug 21, 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

Adds CUBEJS_REFRESH_KEY_LOCAL_TIME (default false): when enabled, interval and cron based refresh_key values are computed from the API instance's own clock instead of via a SELECT FLOOR(...) as refresh_key round trip — the default { every: '10 seconds' } key carries renewalThreshold: 10, so today it is re-issued to Cube Store on nearly every request just to read the wall clock. The compiler already derived every input in JS (interval, dayOffset, utcOffset), so BaseQuery.everyRefreshKeyParts() is extracted as the single source of truth behind both the rendered SQL and a serializable descriptor, which QueryCache.loadRefreshKey and PreAggregationLoadCache.keyQueryResult then short-circuit. Only every-based keys are affected; refreshKey.sql and incremental keys still run against the database, since the latter are wrapped in CASE WHEN NOW() < <dateTo> against an allocated partition-range param. The flag is gated at emit time as well as consume time, so with it off the refresh-key tuples and every hash derived from them are byte-identical — the existing everyRefreshKeySql assertions pass unedited, which is what proves the emitted SQL did not move. It ships behind a flag because clock skew matters: two nodes straddling an interval boundary compute different contentVersions and can build the same pre-aggregation twice, so a single refresh worker is safe while multiple builders are not.

Test results: cubejs-backend-shared 25/25, cubejs-query-orchestrator test/unit 84/84, cubejs-schema-compiler dist/test/unit 772/774 (the two failures are error-reporter snapshots that already fail on a clean master, confirmed by stashing). Not yet smoke-tested against a live deployment.

🤖 Generated with Claude Code

…ime behind a flag

Interval and cron based `refreshKey` values can now be computed from the API
instance clock instead of via `SELECT FLOOR(...) as refresh_key`, gated by
`CUBEJS_REFRESH_KEY_LOCAL_TIME` (default `false`).

The compiler already derived every input in JS — `interval`, `dayOffset` and
`utcOffset` come from `parseSecondDuration`/`calcIntervalForCronString`. SQL only
contributed `now()`. So `BaseQuery.everyRefreshKeyParts()` is extracted as the
single source of truth that both the rendered SQL and a serializable descriptor
derive from, and when the flag is on that descriptor rides in the refresh-key
tuple's options element. `QueryCache.loadRefreshKey` and
`PreAggregationLoadCache.keyQueryResult` then short-circuit it.

Why: the default `{ every: '10 seconds' }` cube refresh key has
`renewalThreshold: 10`, so it is re-issued to Cube Store on essentially every
request. That query has no `TableScan`, so `is_data_select_query` is false and it
takes the `QueryPlan::Meta` path — correctly bypassing `SqlResultCache`, but still
paying parser + plan + optimize + `collect`, plus
`MetaStoreSchemaProvider::new(get_tables_with_path(false))` on every call, plus a
WebSocket round trip. All to learn the wall clock.

Scope: cube-level `cacheKeyQueries` and pre-aggregation `invalidateKeyQueries`,
including the `10 seconds` and `1 hour` defaults. Excluded are `refreshKey.sql`
and `incremental` keys — the latter are wrapped in
`CASE WHEN NOW() < <dateTo + updateWindow>` against an allocated partition-range
param, and their options drive `renewalThresholdOutsideUpdateWindow` shortening
for freshly sealed partitions.

Flag off is byte-identical. The gate is applied at emit time as well as consume
time, so the tuples and every hash derived from them are unchanged and no
persisted cache is invalidated on upgrade. The existing `everyRefreshKeySql`
assertions pass unedited, which is what proves the emitted SQL did not move.

Clock skew is why this is flagged. Two nodes straddling an interval boundary
produce different `contentVersion`s, so the same pre-aggregation gets built twice,
recurring at each boundary. `externalRefresh` bounds the blast radius — non-builder
API instances never run these queries — so a single refresh worker is safe;
multiple workers or `CUBEJS_PRE_AGGREGATIONS_BUILDER=true` API instances are not.
Flipping the flag also forces one pre-aggregation rebuild each way, since `pg`
returns `numeric` as a string and Cube Store as a number. Table names are
unaffected; `getStructureVersion` excludes invalidation keys.

Two findings worth recording:

- `preAggregationInvalidateKeyQueries` is memoized against a `queryCache` whose key
  did not include the flag, so a flag-on query's compiled refresh keys leaked to a
  flag-off query. Fixed by adding `localRefreshKey` to the key list next to the
  analogous `convertTzForRawTimeDimension`. A test caught this, not review.
- The `this.queryResults` memo in `keyQueryResult` is load-bearing, not an
  optimization. One pre-aggregation load reads the invalidation keys three times
  (`contentVersion`, the returned `refreshKeyValues`, the refresh queue key), so
  re-reading the clock would let a load crossing a boundary look a table up under
  one content version and enqueue it under another.

Also annotates a pre-existing mismatch in `hasKeyQueryResult`, which hashes the
whole 3-element tuple while `keyQueryResult` stores under `[query, values]` — it
already returns false unconditionally. Left as a TODO because aligning the keys
would change sync-vs-background refresh semantics for every pre-aggregation.

| Suite | Result |
| --- | --- |
| `cubejs-backend-shared` `test/env.test.ts` | 25/25 |
| `cubejs-query-orchestrator` `test/unit` | 84/84, 5 suites |
| `cubejs-schema-compiler` `dist/test/unit` | 772/774, 37/38 suites |

The two schema-compiler failures are `error-reporter` snapshots that already fail
on a clean tree, confirmed by stashing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ovr
ovr requested review from a team and keydunov as code owners August 21, 2026 12:03
@github-actions github-actions Bot added the javascript Pull requests that update Javascript code label Aug 21, 2026
Conflict in PreAggregationLoadCache.keyQueryResult, from #11608
("normalize pre-aggregation SQL to tuples"), which dropped the
`Array.isArray` normalization on the line this branch built on.

Resolved by taking master's tuple destructuring and keeping the local
`redisKey` binding:

    const [query, values, queryOptions] = sqlQuery;
    const redisKey = this.queryCache.queryRedisKey([query, values]);

The same normalization was dropped from `QueryCache.loadRefreshKey`;
git merged that one cleanly and the local-refresh-key short-circuit
still sits directly below it.

#11608 also narrows `renewQuery`/`startRenewCycle` to accept `string`
rather than `string | QueryWithParams` and splits
`replacePreAggregationTableNames` into a SQL-only variant. Neither
touches the refresh-key path, and the now-guaranteed tuple shape only
reinforces the `hasKeyQueryResult` TODO added on this branch.

| Suite | Result |
| --- | --- |
| `cubejs-backend-shared` `test/env.test.ts` | 25/25 |
| `cubejs-query-orchestrator` `test/unit` | 84/84, 5 suites |
| `cubejs-schema-compiler` `dist/test/unit` | 772/774, 37/38 suites |

The orchestrator run includes #11608's own
`ReplacePreAggregationTableNames.test.ts`. The two schema-compiler
failures are `error-reporter` snapshots that already fail on master.
`yarn tsc` and eslint are clean in the orchestrator.

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

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 7m 3s —— View job


Review complete — solid, well-scoped change. 1 medium, 5 low/nit (posted inline).

Full review

Todos

  • Gather context / read diff
  • Review BaseQuery descriptor emission
  • Review query-orchestrator consumption
  • Review env flag, tests, docs
  • Post review

What I verified

The core refactor holds up. everyRefreshKeyParts() is a faithful extraction: the non-cron branch reproduces utcOffset/parseSecondDuration exactly, the cron branch delegates unchanged to calcIntervalForCronString, and everyRefreshKeySql now renders from those same values — so the SQL genuinely cannot drift from the descriptor. everyRefreshKeySql has no overrides anywhere in src/, and the Tesseract planner does not generate refresh keys (PreAggregations.ts:320 routes through BaseQuery.preAggregationInvalidateKeyQueries), so there is no second code path this misses.

The query parameter threaded into localRefreshKeyOptions is the right call — with externalQueryClass, everyRefreshKeySql delegates to the external instance, and the descriptor has to come from that same instance. The MssqlQuery test covers it.

Opt-out coverage is correct at all four emit sites: refreshKey.sql (cube and pre-agg) returns before the descriptor is attached, and incremental is excluded because the key gets wrapped in CASE WHEN NOW() < <dateTo> against an allocated partition-range param. The conservative choice to opt out even when the timeDimensions guard skips the wrapping is right.

The floor-arithmetic claim in evaluateLocalRefreshKey's comment checks out: for integer x, f ∈ [0,1), interval ≥ 1, the half-open range (x, x+f] contains no integer, so no multiple of interval lies in it and the fractional milliseconds can never move the bucket.

isValidLocalRefreshKey is a genuinely useful guard, not defensive noise — calcIntervalForCronString produces utcOffset: NaN when this.timezone is set but refreshKey.timezone is not (pre-existing, BaseQuery.js:4826-4828), and this catches it and falls back rather than emitting a garbage key.

I also confirmed @AsyncDebounce on loadRefreshKey only dedups in-flight calls (the LRU entry is deleted in finally), so it cannot pin a stale local value; and renewQuery reads the refresh keys exactly once per call, so the split-brain risk the PreAggregationLoadCache memo guards against does not have an analogue on the QueryCache side.

Findings

# Severity Where Issue
1 Medium QueryCache.ts:918 refreshKeyRenewalThreshold silently bypassed — changes invalidation cadence from max(interval, threshold) to interval
2 Low environment-variables.mdx:1406 Docs should note the one-time full pre-agg rebuild on toggle (string → number refresh_key) and fleet-synchronized invalidation
3 Low QueryCache.ts:64 cron field on the descriptor has no consumer
4 Low PreAggregationLoadCache.ts:226 Large TODO for a pre-existing unrelated bug; better as a filed issue
5 Nit QueryCache.abstract.ts:422 Boundary-straddling flake — assert against a mocked Date.now()
6 Nit QueryCache.ts:224 Comment cites an initialization-order hazard that doesn't exist (parameter properties are assigned pre-body)

Smaller observations (not worth inline threads)

  • QueryCacheOptions.localRefreshKey is reachable from QueryOrchestrator (...options.queryCacheOptions) but the Joi schema at optionsValidate.ts:127 uses .keys({...}) without the field, so user config would be rejected — it is effectively test-only. That's fine, but it means the emit side (env, via server.tsCompilerApi) and the consume side (env, via getEnv fallback) are two independent reads of the same variable. Both default off and both read the same env, so no realistic split, but a comment noting they must stay in lockstep would help.
  • env.test.ts refreshKeyLocalTime(exception) deletes the env var after the expect rather than in a finally, so a failure leaks 'yes' into later tests. Matches the surrounding file's style, so take it or leave it.
  • base-query.test.ts 'emits no descriptor when the flag is off' will fail if CUBEJS_REFRESH_KEY_LOCAL_TIME=true is set in the environment. Consider passing localRefreshKey: false explicitly.
  • No test covers a cron-based key end-to-end through PreAggregationLoadCache with the flag on — the pieces are each tested, the seam is not.

Praise

The everyRefreshKeyParts agrees with the SQL it renders test — evaluating the emitted FLOOR(...) string as JS with a pinned clock and comparing against the descriptor arithmetic — is exactly the right property to assert for a refactor whose whole safety argument is "these two cannot disagree." Gating at emit time so the existing everyRefreshKeySql assertions pass unedited is also the correct proof that the SQL didn't move, and the boundary-stability test on the per-request memo shows the split-brain hazard was thought through rather than stumbled into.

Security: nothing to flag. The descriptor is compiler-produced numbers, validated before use, and never reaches SQL.

• branch [`refresh-key-local-time-flag`](https://github.com/cube-js/cube/tree/refresh-key-local-time-flag)

Comment on lines +918 to +925
// A locally evaluated key is free, so there is nothing to cache and no queue to
// wait on. The value is quantized to the interval, so repeated calls agree
// except across a boundary — the same window the cached SQL result had.
const local = this.localRefreshKeyResult(queryOptions);
if (local) {
return local;
}

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.

refreshKeyRenewalThreshold is silently bypassed on the local path.

The SQL path below resolves renewalThreshold: this.options.refreshKeyRenewalThreshold || queryOptions?.renewalThreshold || 2 * 60, and PreAggregationLoadCache.keyQueryResult does the same (PreAggregationLoadCache.ts:211). Because the cached refresh-key value is only re-read every renewalThreshold seconds, the observed invalidation cadence today is effectively max(interval, renewalThreshold). The local path returns before any of that, so the cadence becomes exactly interval.

For the defaults this is a no-op (refreshKeyRenewalThresholdForInterval returns min(interval/10, 300), always below the interval). But orchestratorOptions.queryCacheOptions.refreshKeyRenewalThreshold is a supported user knob (optionsValidate.ts:128), and someone who set it to e.g. 300 against the default every: '10 seconds' key is deliberately throttling invalidation 30×. Turning this flag on would silently restore 10s invalidation of their query cache and pre-aggregation content versions — i.e. a large increase in real DB queries, which is the opposite of the PR's intent.

Cheapest fix is to quantize to the effective threshold rather than the raw interval, e.g. pass Math.max(descriptor.interval, this.options.refreshKeyRenewalThreshold || queryOptions?.renewalThreshold || 0) into evaluateLocalRefreshKey. At minimum this interaction should be called out in the docs.

Fix this →


<Warning>

Set this variable to the same value on **every** API instance and refresh worker, and keep

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.

Two operational consequences worth adding to this warning:

  1. Flipping the flag invalidates every pre-aggregation once. SELECT FLOOR(...) as refresh_key comes back as a string from several drivers (Postgres bigint/numeric, BigQuery INT64), while evaluateLocalRefreshKey returns a JS number. PreAggregationLoader.contentVersion hashes the invalidation key values, so [{refresh_key: "12345"}] and [{refresh_key: 12345}] produce different content versions and every rollup rebuilds on the first request after the toggle. That is a one-time cost, but on a large deployment it is worth warning about (and it applies in both directions).

  2. Invalidation becomes fleet-synchronized. Today each node caches its refresh-key result independently, so the moment a node notices a new interval is staggered by whenever it last fetched. With synchronized clocks every node flips at the exact same instant, so the primary-query caches across the fleet all miss simultaneously at each boundary. Not incorrect, but a thundering-herd shape that operators should expect.

interval: number;
utcOffset: number;
dayOffset: number;
cron?: boolean;

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: cron is never read — neither evaluateLocalRefreshKey nor isValidLocalRefreshKey looks at it, and everyRefreshKeyParts only uses it internally in everyRefreshKeySql to pick the branch. It rides along in every serialized invalidateKeyQueries tuple for no consumer. Either drop it from the descriptor (keep it as a local in everyRefreshKeySql) or add a comment saying it is retained deliberately for debuggability.

Comment on lines +226 to +231
// TODO hashes the whole [query, values, options] tuple, while keyQueryResult stores
// under [query, values]. Every compiler-produced invalidateKeyQueries entry carries an
// options object, so this always returns false, which pins PreAggregationLoader to the
// background-refresh branch whenever waitForRenew is false. Aligning the keys would
// change sync-vs-background refresh semantics for every pre-aggregation, so it needs to
// land on its own rather than riding along with an unrelated change.

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.

The analysis here is correct (queryRedisKey on a 3-element tuple never matches the 2-element key keyQueryResult stores under, so notLoadedKey at PreAggregationLoader.ts:134 is always truthy), but this is a pre-existing bug in an unrelated method and a six-line TODO is easy to lose in a diff. Consider filing it as an issue and shortening this to a one-line // TODO(#NNNNN) pointer, so it doesn't read as something this PR introduced or is responsible for.

});

expect(executed).toBe(0);
expect(result).toEqual([{ refresh_key: Math.floor(Date.now() / 1000 / 600) }]);

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 asserts exact equality against a clock read after the call, so it fails whenever the test happens to straddle a 600-second boundary. Rare, but these are the flakes that show up once a month in CI and cost an hour to diagnose. Same pattern at PreAggregations.test.ts:327.

jest.spyOn(Date, 'now').mockReturnValue(...) — which the boundary-stability test in PreAggregations.test.ts already uses — removes the race entirely.

Comment on lines +224 to +225
// Logged on first use rather than from the constructor, where subclass field
// initialization has not run yet and `this.logger` may not be usable.

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: the stated reason isn't right. logger is a TypeScript parameter property, so it is assigned before the constructor body runs — this.logger(...) from the constructor would work. The actual (and better) justification for lazy logging is that the warning should only fire when a descriptor is genuinely used, not on every QueryCache construction in a multi-tenant process. Worth rewording so the comment doesn't send the next reader looking for an initialization-order hazard that isn't there.

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.40%. Comparing base (c290698) to head (03e0d9f).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11614      +/-   ##
==========================================
+ Coverage   59.32%   59.40%   +0.07%     
==========================================
  Files         228      228              
  Lines       18206    18234      +28     
  Branches     3672     3679       +7     
==========================================
+ Hits        10801    10831      +30     
+ Misses       6858     6855       -3     
- Partials      547      548       +1     
Flag Coverage Δ
cube-backend 59.40% <100.00%> (+0.07%) ⬆️

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.

@mintlify

mintlify Bot commented Aug 21, 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 Aug 21, 2026, 2:03 PM

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

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.

2 participants