Skip to content

Gate pre-aggregation routing on reported freshness (opt-in) - #2381

Closed
shangyian wants to merge 2 commits into
mainfrom
preagg-freshness-gating
Closed

Gate pre-aggregation routing on reported freshness (opt-in)#2381
shangyian wants to merge 2 commits into
mainfrom
preagg-freshness-gating

Conversation

@shangyian

Copy link
Copy Markdown
Collaborator

Summary

DJ stores the valid_through_ts an external pipeline reports for a pre-aggregation and exposes it over the API and GraphQL, but nothing ever consulted it when deciding whether that pre-aggregation may answer a query. A pre-agg whose pipeline died months ago kept serving queries silently. This adds gating, off by default.

The rule: compare against the query's own temporal filter, not the wall clock. A query for last March doesn't care that a table stopped updating yesterday, so a wall-clock staleness budget rejects pre-aggs that are perfectly valid for the range requested.

There's a second, stronger reason to avoid wall-clock as the primary rule: valid_through_ts's encoding is not agreed on inside this repo. The docs describe it as the table's partition format (20260721), models/preaggregation.py documents it as "Timestamp (epoch)", the GraphQL scalar comments mention millisecond timestamps, and test fixtures use all three. A wall-clock budget has to interpret that integer as a point in time. Comparing against a filter literal never interprets it — it only compares two values written in the same encoding, whatever that encoding is.

For a query with no upper bound (the actual dead-pipeline case), a wall-clock budget does apply, but still without parsing the watermark: it renders now - budget into the partition format and compares. Formats it can't render decline to judge.

Where it gates

In load_available_preaggs, alongside the existing availability filter — not in AvailabilityState.is_available(), which has no access to the build context and so can't see the query's temporal range. That TODO stays open.

Configuration

Both settings preserve today's behavior by default:

  • preagg_freshness_gating: bool = False — master switch.
  • preagg_max_staleness_seconds: Optional[int] = None — budget for open-ended queries only; None means they're never rejected on staleness.

Deliberately conservative elsewhere: filters containing OR or a negated BETWEEN are ignored rather than trusted, < lowers the bound by one, a pre-agg with no temporal grain is never judged, and if any temporal axis is unbounded the whole query is treated as open-ended. The extra eager loading is added to the pre-agg query only when the gate is on, so the default path's query cost is unchanged.

Behavior on rejection

A rejected pre-agg falls back to source, exactly like any other non-match — results stay correct, just slower. That's the right default; failing the query would be worse than what we have today.

The signal that matters is operational rather than per-caller: a pre-agg rejected for staleness on every query means a broken pipeline, and that belongs in monitoring. The logger.info in preagg_is_fresh is the hook. Not included in this PR — flagging it as the natural follow-up rather than building a new surfacing mechanism here.

Test Plan

datajunction_server/construction/build_v3/loaders.py              180      0     88      0   100%
datajunction_server/construction/build_v3/preagg_freshness.py     113      0     60      0   100%
920 passed, 1 xfailed in 195.52s

Both touched modules at 100% statement and branch coverage. pre-commit run --from-ref main --to-ref HEAD clean including mypy.

  • PR has an associated issue: #
  • make check passes
  • make test shows 100% unit test coverage

Deployment Plan

No migration. Off by default, so deploying changes nothing until preagg_freshness_gating is enabled.

Note for reviewers

Touches docs/content/0.1.0/docs/dj-concepts/aggregate-awareness.md, which conflicts with #2380. Merge #2380 first and I'll rebase this. #2380's "freshness doesn't gate routing yet" bullet stays accurate for the default configuration and needs amending rather than removing.

The valid_through_ts encoding disagreement described above is worth fixing separately — the design here works around it rather than resolving it, and the field descriptions are wrong per the docs.

DJ stored an external pipeline's `valid_through_ts` for each pre-aggregation
and exposed it over the API, but nothing consulted it when deciding whether
that pre-agg could answer a query. A pre-agg whose pipeline died kept serving
silently truncated numbers.

The gate compares the watermark against the time range the query itself asks
for rather than against wall clock, so a report bounded at last March still
reads an aggregate that stopped updating months ago, while a query reaching
past the watermark falls back to source. Both sides of the comparison stay in
the partition format, so `valid_through_ts` is never reinterpreted as a point
in time. A query with no discoverable upper bound implicitly asks for data
through the present; those are allowed unless a wall-clock staleness budget is
also configured, in which case "now minus the budget" is rendered into the
partition format and compared.

Off by default behind `preagg_freshness_gating`, since turning it on can route
queries away from pre-aggs that serve them today. The extra eager loading the
gate needs is only added to the pre-agg query when it is enabled.

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

netlify Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploy Preview for thriving-cassata-78ae72 canceled.

Name Link
🔨 Latest commit 9329927
🔍 Latest deploy log https://app.netlify.com/projects/thriving-cassata-78ae72/deploys/6a6ce27b43e14b0008495592

The gate was 271 lines for what is one comparison. Most of the excess came from
re-implementing machinery that already existed and from supporting a case that
cannot arise.

Shares the traversal. temporal_grain_refs was a second copy of the temporal
column to grain matching in get_temporal_partitions, and had already drifted:
it implemented two of that function's three strategies, so a pre-agg whose
temporal axis reaches the grain only through the column's own dimension
reference was never gated at all. Both now go through
match_temporal_columns_to_grain, which also collapses three copies of the
output-name derivation in get_temporal_partitions into one.

Collapses the multi-axis support. A pre-agg reports one watermark, so bounds
read off two axes could be in different partition formats and compared against
a watermark in only one of them -- and the wall-clock branch already gave up
and used the first format. Now judged only when exactly one temporal column is
in the grain, matching the collapse combiner construction already makes.

Reads the filters once. Bound extraction moves to filters.py as
upper_bounds_by_ref, memoized per node revision on the build context: it was
re-parsing every filter once per temporal ref per candidate pre-agg. It also
reads reversed operands now, so `20250101 >= date_id` is a bound rather than
silently unbounded, and it uses the public get_column_full_name instead of
filters.py's private _extract_full_column_ref.

Drops the eager-load branch in load_available_preaggs. Every relationship it
added is already loaded on the same objects by
batch_load_nodes_with_dependencies, and Column.partition is lazy="joined", so
it bought two discarded round trips and re-selected the large query column that
the main loader deliberately restricts away.

Says when it cannot judge. valid_through_ts has no agreed encoding in this repo
-- partition format, epoch seconds and epoch milliseconds all appear -- so a
deployment following the documented epoch contract would compare 20250101
against 1.7e12, pass everything forever, and report nothing. An
order-of-magnitude mismatch now logs and declines.

Also: render_partition_value moves to models/partition.py beside the SQL
renderer and rejects formats whose token order is not chronological
(ddMMyyyy); the wall clock is read once per build from ctx.build_timestamp
rather than per pre-agg; and the tests use a real Settings instance, so
renaming a setting fails loudly instead of leaving them green while the
feature stops working.

Net: 271 -> 43 statements in the gate, and Strategy 3 is covered by tests
rather than pragma'd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shangyian shangyian closed this Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant