Skip to content

feat: continuous analytics (lastYears=0) support for Doris#24440

Draft
jason-p-pickering wants to merge 23 commits into
masterfrom
feat/doris-continuous-analytics
Draft

feat: continuous analytics (lastYears=0) support for Doris#24440
jason-p-pickering wants to merge 23 commits into
masterfrom
feat/doris-continuous-analytics

Conversation

@jason-p-pickering

@jason-p-pickering jason-p-pickering commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds continuous/incremental analytics table updates (lastYears=0) for Apache Doris, matching functionality that already exists for PostgreSQL. Previously, Doris only supported full analytics table rebuilds.

  • Two new SqlBuilder capability flags: supportsContinuousAnalytics() (Postgres/Doris=true, ClickHouse=false — ClickHouse continuous analytics was concluded not feasible in earlier investigation) and requiresUniqueKeyAnalyticsTables() (Doris-only, kept separate so Postgres/ClickHouse table DDL is unchanged).
  • DATA_VALUE, COMPLETENESS, and EVENT Doris analytics tables now use a composite UNIQUE KEY(id, year) / UNIQUE KEY(event, year) model instead of DUPLICATE KEY — required because Doris rejects DELETE ... WHERE x IN (SELECT ...) outright, only supports DELETE ... USING <join> on UNIQUE KEY tables, and requires the partition column (year) to be part of the key for its Merge-on-Write model.
  • removeUpdatedData() rewritten in the three affected table managers to emit Doris's DELETE...USING form.
  • swapTable()/DefaultAnalyticsTableService wired so the mechanism only activates for the true continuous (lastYears=0) case; ClickHouse and Postgres behavior is unchanged, and bounded partial rebuilds (lastYears=N>0) are unaffected.

Bugs found and fixed during review/validation (beyond the initial implementation)

This PR went through unusually thorough post-implementation validation — a full whole-branch code review, then live testing against a real Doris instance, then a full manual create/update/delete/lastYears=0 lifecycle test with before/after evidence. That process found and fixed six real bugs:

  1. Doris CREATE TABLE column ordering — Doris requires unique-key columns to be a leading schema prefix; fixed by reordering the rendered column list.
  2. swapTable() staging-table catalog qualification — was referencing the native Doris staging table through the federated Postgres catalog prefix; fixed to use native quote(...).
  3. Composite unique key required — Doris's Merge-on-Write model requires the partition column (year) to be part of the key; validated live against real Doris before implementing.
  4. swapTable() insert-gate scope leak — was reachable for lastYears=N>0 (not just lastYears=0), which would have started merging fresh data into the main table for bounded rebuilds without ever purging deleted-at-source rows. Narrowed to isLatestUpdate() only.
  5. id key column typeDataType.TEXT maps to Doris's unbounded string type, which Doris rejects as a key-column type. Caught by real CI (run-api-analytics-tests-doris.yml) aborting the entire analytics build. Fixed with a bounded VARCHAR_255 on Doris only.
  6. DorisSqlBuilder.tableExists() checked the wrong schema ('public', the Postgres-catalog convention, instead of the actual connected database) — so it always returned false for real Doris tables, so swapTable()'s skipMasterTable would always evaluate false. Concretely: had this shipped as-is, every continuous (lastYears=0) run on Doris would have replaced the entire main analytics table with just the latest-window data, destroying all historical data outside that window.
    • The wrong-schema check itself is pre-existing code — unchanged on master today, not introduced by this PR.
    • We have no evidence this ever executed against a real Doris deployment, and confirmed it could not have: before this PR, removeUpdatedData() unconditionally issued DELETE ... WHERE x IN (SELECT ...), a form Doris rejects outright. Any lastYears=0 attempt against Doris pre-PR would have failed with a SQL error at that earlier step, before swapTable() — and therefore before tableExists() — was ever reached. This is consistent with continuous analytics on Doris never having been supported before this PR.
    • The actual risk was prospective, to this PR's own new code: the removeUpdatedData() DELETE...USING rewrite and supportsContinuousAnalytics() gating added here are what first made swapTable()'s skipMasterTable branch reachable for Doris at all. Left unfixed, this PR would have introduced the silent-data-loss bug as part of shipping the feature — not resurrected a dormant production issue. Caught via manual create→update→delete→lastYears=0 testing against a live Doris instance before merge, invisible to unit tests (mocked JDBC), the whole-branch review, and CI's E2E test (which never exercises a second lastYears=0 run against pre-existing data — see the follow-up ticket below). Fixed by using database() instead of the hardcoded schema literal.

Manual validation performed

After the tableExists() fix (finding 6 above), ran a full live validation against a real Doris 3.0.7 instance + the Sierra Leone DB (same stack CI uses), for both DATA_VALUE (aggregate) and EVENT:

  • Full analytics rebuild → create new data → lastYears=0 → confirmed new data appears.
  • Update existing data → lastYears=0 → confirmed new value reflected, no duplicate row.
  • Delete existing data → lastYears=0 → confirmed row removed.
  • Confirmed historical year partitions (2012–2022+) survive every continuous run — not wiped.
  • Confirmed via server logs that swapTable() takes the insert-into-main path (skip master table: true), not the full-replace path.

Known follow-ups (filed separately, not blocking this PR)

  • No E2E test coverage exists anywhere in the suite (Postgres included) for the actual continuous-update lifecycle (create/update/delete against pre-existing data, then lastYears=0) — CI only validates full-build table creation and read-only queries. This is exactly the gap that let the tableExists() schema bug (finding 6 above) through. Scoped as a separate follow-up PR (aggregate/DATA_VALUE first).
  • lastYears=N>0 (bounded partial rebuilds, as opposed to lastYears=0) appears to be a pre-existing silent no-op on Doris, unrelated to this PR's scope.
  • A general removeUpdatedData() performance gap (missing lastupdated index on trackerevent/completedatasetregistration/singleevent) was found on the Postgres side while investigating — pre-existing, not Doris-specific, not introduced by this PR.
  • A natural-composite-key optimization (dropping the synthetic id column in favor of the already-present dx/co/ao/pe/ou columns) was identified but deliberately deferred as a follow-up, not needed for correctness.

Test plan

  • Unit tests for all changed/new SQL generation (dhis-support-sql, dhis-service-analytics modules)
  • mvn spotless:check clean
  • run-api-analytics-tests-doris.yml (real Doris + Sierra Leone DB) — passing
  • Manual live validation of the full create/update/delete/lastYears=0 lifecycle for aggregate and event types, with before/after database evidence

jason-p-pickering and others added 12 commits July 13, 2026 15:54
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…able catalog qualification

- DorisSqlBuilder.createTable() now reorders the rendered column list so primary
  key columns lead, since Doris requires key columns to be an ordered leading
  prefix of the schema. Aggregate and completeness analytics tables (where id
  is not already column 0) would otherwise fail CREATE TABLE.
- AbstractJdbcTableManager.swapTable()'s Doris insert-into-main branch now
  references the staging table via quote() instead of qualifyTable(), since the
  staging table is a native Doris table, not a federated one (same fix already
  applied to removeUpdatedData() in earlier commits).
…artition requirement

Doris's UNIQUE KEY (Merge-on-Write) model requires the partition column to be
part of the key. Analytics tables are partitioned by year, so the key must be
composite: (id, year) for aggregate/completeness, (event, year) for event.
Confirmed working end-to-end against a real Doris 3.0.7 instance.

Also fixes DorisSqlBuilder.getOrderedColumns(), which built the leading
key-column block from natural column order rather than the primary key's
declared order. For a composite key, Doris requires the leading schema
columns to match the key clause's declared order exactly -- for the
aggregate/completeness tables, year appears before id in natural column
order (FIXED_COLS), which would have produced a mismatched schema.

removeUpdatedData() DELETE...USING SQL is unchanged in all three managers --
it matches on id/event alone and was confirmed to still correctly find and
delete rows regardless of partition.
…st isPartialUpdate()

skipMasterTable is computed from params.isPartialUpdate() (lastYears != null
|| isLatestUpdate()), not isLatestUpdate() alone, so the new Doris
insert-into-main branch was also firing for lastYears=1..N (a normal
bounded-years rebuild), not just lastYears=0 (the continuous/latest-partition
case this plan is scoped to). removeUpdatedData() never runs for
lastYears=N>0, so the branch was merging staging data into main without ever
purging rows deleted at source -- a real regression versus the pre-PR
no-op-drop-only behavior for that path.

Gate the insert branch on params.isLatestUpdate() as well, restoring the
exact pre-PR (safe, no-op) behavior for lastYears=N>0 on Doris while keeping
the insert working for lastYears=0.
@jason-p-pickering jason-p-pickering added the run-api-analytics-tests-doris Enables analytics e2e tests on Doris label Jul 14, 2026
@jason-p-pickering
jason-p-pickering marked this pull request as draft July 14, 2026 05:58
jason-p-pickering and others added 8 commits July 14, 2026 08:17
…ey column)

Real Doris CI (Sierra Leone e2e run) failed with errCode = 2, "String Type
should not be used in key column[id]" while creating the DATA_VALUE staging
table -- aborting the whole analytics table generation job, including
everything after DATA_VALUE in iteration order.

The synthetic id column (concat(...) of several dimension UIDs) was defined
as DataType.TEXT, which DorisSqlBuilder maps to the unbounded "string" type.
Doris rejects unbounded string/text as a key column on any key model.
event/dx don't have this problem -- they're already CHARACTER_11 (bounded
char(11)).

Give id a bounded VARCHAR_255 (Doris: varchar(1020)) type specifically when
sqlBuilder.requiresUniqueKeyAnalyticsTables() is true (Doris only), leaving
Postgres/ClickHouse's TEXT unchanged, in both JdbcAnalyticsTableManager and
JdbcCompletenessTableManager.
…inuous-analytics history preservation

tableExists() filtered on t.table_schema = 'public', but native Doris
analytics tables live in whatever database the connection is on (e.g.
'dhis2'), not 'public' ('public' is only correct for the federated
Postgres catalog). This meant tableExists() always returned false for
real Doris tables, so swapTable()'s skipMasterTable was always false,
so every continuous (lastYears=0) run silently replaced the entire
main analytics table with just the latest-window data instead of
inserting into the existing table - destroying history.

Found via a live manual test against a real Doris instance: triggered
lastYears=0, and confirmed the main table's row count and partition
history matched exactly what a full replace (not a merge) would
produce. Fixed by using database() instead of the hardcoded schema
name, which correctly resolves to the connection's actual database.
Re-verified live after the fix: history preserved, create/update/delete
all correctly reflected, "skip master table: true" confirmed in logs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
JUnit 5 test lifecycle methods don't need public visibility; the rest
of the class already uses package-private.
@netroms

netroms commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

The Doris analytics CI failures (12 download tests with HTTP 406) are a master regression from #24463, not from this PR.

Fix opened: #24479

Stripped-path content negotiation was matching the JSON produces handler first and throwing 406 before the literal .xml/.csv/.xls/.xlsx download mapping could run. Once #24479 lands, re-run should clear those failures.

@sonarqubecloud

Copy link
Copy Markdown

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

Labels

run-api-analytics-tests-doris Enables analytics e2e tests on Doris

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants