Skip to content

E2E: Cover House Tables against a deployed MySQL - #698

Closed
ruolin59 wants to merge 4 commits into
mainfrom
rufan/hts-deployment-tests
Closed

ruolin59 wants to merge 4 commits into
mainfrom
rufan/hts-deployment-tests

Conversation

@ruolin59

@ruolin59 ruolin59 commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Add deployment-level House Tables E2E coverage against MySQL initialized from the current recorded DDL. This PR now targets main; it no longer depends on the closed #697 branch. Production service code and DDL are unchanged from upstream.

Changes

  • Client-facing API Changes

  • Internal API Changes

  • Bug Fixes

  • New Features

  • Performance Improvements

  • Code Style

  • Refactoring

  • Documentation

  • Tests

  • Add an oh-only-mysql recipe that applies services/housetables/ddl/*.sql in filename order on a fresh volume. HTS waits for an authenticated TCP schema query to succeed, avoiding the reproduced first-start race with MySQL initialization.

  • Add 25 HTTP/SQL E2E cases covering typed table/view lifecycles, conflicts, legacy NULL discriminators, restore stamping, corrupt-value diagnostics, and mixed-case identifiers. View listings use the current /v1/hts/views/query API, not the removed legacy route.

  • Check the updated live-table column order, identifier/metadata widths, nullable version/discriminator, recorded table collations, and idx_user_table_upper_db_table functional index. This detects stale volumes or accidental use of the service's different bootstrap schema.

  • Run both the existing tables integration suite and the new HTS suite against MySQL in CI. Readiness polling has bounded HTTP requests, validates expected statuses, and prints diagnostics on timeout. --require-database makes unavailable database coverage fail instead of skip.

  • Document local setup, strict versus optional database mode, and the fresh-volume requirement.

  • Add python3 scripts/python/run_local_e2e.py as the README-documented, one-command local entrypoint. It reuses the Compose recipe, builds both services, installs test dependencies in a temporary virtual environment, waits for readiness, and runs both suites against a fresh isolated database. Random loopback ports avoid conflicts with existing stacks; its own containers and volumes are removed afterward. The existing tables test accepts an optional OPENHOUSE_TABLES_HOST, preserving localhost:8000 by default.

Testing Done

  • Manually Tested on local docker setup. Please include commands ran, and their output.
  • Added new tests for the changes made.
  • Updated existing tests to reflect the changes made.
  • No tests added or updated. Please explain why. If unsure, please feel free to ask for help.
  • Some other form of testing like staging or soak time in production. Please explain.

Built the current upstream services with Java 17:

./gradlew :services:housetables:bootJar :services:tables:bootJar -x CopyGitHooksTask

Started the recipe as Compose project oh698, with a local-only override exposing MySQL on 127.0.0.1:13306 and giving OPA a unique container name. The existing MySQL container on port 3306 was left untouched. Recreated the deployment with a fresh anonymous MySQL volume to verify the DDL sequence and health-gated startup, then ran:

HTS_DB_PORT=13306 python3 scripts/python/hts_integration_test.py http://localhost:8001 --require-database
# 25 passed, 0 skipped

python3 scripts/python/integration_test.py tables-test-fixtures/tables-test-fixtures-iceberg-1.2/src/main/resources/dummy.token
# All tests passed successfully

Both suites passed on the warm deployment and again after cold initialization on MySQL 8.4.11. With the database port deliberately unreachable, strict mode exited nonzero before running HTTP cases; optional mode reported 21 passed, 4 skipped.

Also ran the new entrypoint end to end:

python3 scripts/python/run_local_e2e.py
# 25 passed, 0 skipped
# All tests passed successfully
# Both E2E suites passed; test containers and database removed.

This separate deployment used dynamically assigned ports while the existing stacks remained running. Verified scoped container cleanup after both a failed startup-readiness attempt and the successful run.

./gradlew spotlessCheck -x CopyGitHooksTask passed. CopyGitHooksTask was excluded because it assumes .git is a directory and fails in a Git worktree; the commit/push formatting check was run directly with that task excluded. Compose configuration validation and git diff --check also passed.

Additional Information

  • Breaking Changes
  • Deprecations
  • Large PR broken into smaller PRs, and PR plan linked in the description.

CI now exercises the MySQL recipe instead of oh-only; the H2 recipe remains unchanged but is not exercised by this CI job. Corrupt-discriminator expectations are pinned to the recorded utf8mb4_0900_ai_ci NO PAD collation, including the explicit 404-vs-500 typed/neutral disagreement for malformed values.

The recorded live and soft-deleted table schemas are production-verified upstream. job_row and table_toggle_rule remain bootstrap-derived approximations, as documented in the baseline. Docker initialization runs only on an empty data volume; it does not migrate an existing volume.

The branch is rebased onto main with only the E2E changes. Index metadata is checked, but query-plan/index-selection assertions are intentionally out of scope.

@ruolin59 ruolin59 changed the title BDP-108627: Cover House Tables against a deployed MySQL E2E: Cover House Tables against a deployed MySQL Aug 27, 2026
@ruolin59
ruolin59 force-pushed the rufan/hts-deployment-tests branch from 003d58b to 94bb668 Compare August 28, 2026 00:43
ruolin59 added a commit that referenced this pull request Sep 10, 2026
…TS (alternative to #697) (#703)

## What this is

An **alternative implementation of BDP-108627**, the same ticket as
**#697**. It is meant to be compared against that PR, not stacked on it.
Both branch from `rufan/views-entity-type-discriminator`.

The premise: keep #697's HTTP contract and its SQL layer as they are,
and rebuild the part between them — the CRUD wiring and error handling.
One deliberate exception, called out below: views are paginated-only, so
the non-paginated `GET /hts/views/query` that #697 mirrored from tables
is dropped.

| | #697 | this PR |
|---|---|---|
| HTTP API | — | **identical except views are paginated-only** — the one
non-paginated view route is removed by design (see below); every other
route, verb, param, body and status is unchanged |
| SQL / JPQL | — | **identical** apart from removing the two now-dead
non-paginated view finders; every constant, table finder and paginated
view finder is verbatim |
| Controller ↔ repository wiring | as landed | rebuilt |

## What is deliberately unchanged

**The HTTP surface.** `GET /hts/entities` and every pre-existing table
route keep their paths, verbs, parameters, defaults, bodies and status
codes. The view routes match #697's with one exception: there is no
non-paginated `GET /hts/views/query` (see the next section).

**The SQL layer.** Every JPQL constant (`COMMON_FILTER_CLAUSES`,
`PATTERN_KEY_CLAUSES`, `TABLE_ROW_PREDICATE`, `VIEW_ROW_PREDICATE`,
`STAMP_TABLE_TYPE`), every query and `countQuery`, the typed conditional
deletes returning an affected-row count, and the type-scoped rename are
carried over verbatim. `TABLE_ROW_PREDICATE` keeps its `IS NULL` arm, so
legacy rows still read as tables.

## JDBC repository surface (`UserTableHtsJdbcRepository`)

An accounting of the MySQL-backed repository methods, grouped by the
entity type each targets. "New" and "reshaped" are relative to the base
branch `rufan/views-entity-type-discriminator`, which already carried
the table-typed **reads** (the frozen tables path) but only **neutral
writes**.

**View-scoped — every method is new in this PR** (the repository had no
view methods before):

| Method | Returns | Purpose |
|---|---|---|
| `findViewByDatabaseIdIgnoreCaseAndTableIdIgnoreCase(db, table)` |
`Optional<UserTableRow>` | point read under `VIEW_ROW_PREDICATE` |
| `findAllViewsByFilters(db, table, version, metadataLocation,
storageType, creationTime, Pageable)` | `Page<UserTableRow>` | paginated
multi-attribute filter |
| `findAllViewsByDatabaseIdAndTableIdLikeAllIgnoreCase(db, pattern,
Pageable)` | `Page<UserTableRow>` | paginated name-pattern list |
| `deleteViewByDatabaseIdIgnoreCaseAndTableIdIgnoreCase(db, table)` |
`int` | conditional delete under `VIEW_ROW_PREDICATE`, affected-row
count |
| `deleteViewById(key)` | `int` | default delegating to the delete above
|

Plus the constants `VIEW` and `VIEW_ROW_PREDICATE`. There is
deliberately **no** non-paginated view finder, no `findViewById`
default, and no view rename: views are paginated-only and are not
renameable.

**Table-scoped:**

- `findTableByDatabaseIdIgnoreCaseAndTableIdIgnoreCase → Optional`,
`findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase` (`Iterable` and
`Page`), `findAllTablesByFilters` (`Iterable` and `Page`) — *inherited
from base*, unchanged.
- `deleteTableByDatabaseIdIgnoreCaseAndTableIdIgnoreCase → int` and
`deleteTableById(key) → int` — **new**: replace the base's single
neutral `void delete…`, now scoped by `TABLE_ROW_PREDICATE` and
returning an affected-row count so the service maps missing and
wrong-type alike to 404.
- `renameTableId(…) → int` — **reshaped**: the base's neutral `void
renameTableId` gains `TABLE_ROW_PREDICATE` scoping and
`STAMP_TABLE_TYPE` (rewriting a legacy null or non-canonical spelling as
the row moves), and returns a row count. Table-only because views are
not renameable.

**Both (type-neutral):**

- `findByDatabaseIdIgnoreCaseAndTableIdIgnoreCase → Optional` and
`existsByDatabaseIdIgnoreCaseAndTableIdIgnoreCase → boolean` —
*inherited*; the writers read through these so they can see a row of any
type and detect a collision at a shared key, which is why they stay
unfiltered.
- `findAllByDatabaseIdAndTableIdLikeAllIgnoreCase` (`Iterable` and
`Page`), `findAllByFilters` (`Iterable` and `Page`),
`findAllDistinctDatabaseIds()` and its paginated form — *inherited*.
- `findById` / `existsById` defaults — *inherited*.
- `deleteById`, `delete`, `deleteAllById`, `deleteAll(Iterable)`,
`deleteAll()` — **sealed in this PR** to throw
`UnsupportedOperationException`, forcing callers onto the typed deletes;
the base's neutral `deleteBy…` derived delete is removed.


## What is rebuilt, and why

**A view write cannot persist a table.** `putUserTable` and
`putUserView` each supply their own `EntityType` to one private
persistence primitive, so the named method — not the payload — is the
authority. In #697, `putView` delegates to `putUserTable` and relies on
the controller having stamped the type; a direct Java caller could
persist the wrong one.

**"Not found" cannot be manufactured from a failure.** The new point
reads return `Optional`, and only the handler turns empty into a 404. A
dependency or hydration failure travels as an exception and surfaces as
a 500. This matters most for the neutral read, where the ticket calls
out "the name is free" as the dangerous default.

**The service owns its view-query vocabulary.** View queries cross the
boundary as an owned `UserViewQuery`, built once in the handler after
validation, so the transport `UserTable`'s nullability and its inert
`entityType` stop at the handler. The invalid "table pattern without a
database" state cannot be constructed.

**Corruption is translated at the repository boundary, structurally.**
An `@Around` aspect (`UserTableRepositoryTranslationAspect`) wraps every
`UserTableHtsJdbcRepository` method: when a corrupt discriminator
surfaces during hydration it is rethrown as an unwrapped
`CorruptEntityTypeException`, and every other `DataAccessException` is
rethrown exactly as it arrived, so non-corrupt failures behave as
before. Because it is an aspect over the whole repository rather than a
wrap at each call site, a read added later is covered without anyone
remembering to wrap it. The interception point is load-bearing and can
fail silently — Spring Data's own proxy holds
`PersistenceExceptionTranslationInterceptor`, and the aspect must land
in the outer proxy to see the already-translated `DataAccessException` —
so a structural test pins it (`RepositoryTranslationAspectTest`) and a
Spring Data upgrade that relocated the interceptor fails loudly rather
than downgrading every corrupt read to a generic 500.

Because corruption is unwrapped before it leaves the repository, the
shared advice gains **one handler and no ORM vocabulary**. #697 instead
teaches `services/common` to import and unwrap `JpaSystemException` and
`InvalidDataAccessApiUsageException`.

`CorruptEntityTypeException` does not extend `IllegalArgumentException`,
so stored-data corruption stays distinguishable from client-input error.

**Views are paginated-only.** #697 gave views a non-paginated `GET
/hts/views/query` mirroring `GET /hts/tables/query`. This PR drops it:
views can be listed only through the paginated `GET
/v1/hts/views/query`. The non-paginated table query is legacy — it still
backs the tables service's `/v0` and `/v1` list-all, and its
empty-filter form returns database names rather than tables — and a
trace confirmed neither the tables service nor the internal catalog
references any view query path. Mirroring that shape would inherit tech
debt into a greenfield surface with no caller, so on this new path we
took the more principled option, which also removes the unbounded-query
concern for views entirely. Tables are untouched: `GET
/hts/tables/query` stays, because it has live callers.

**Smaller ones.** PUT ingress validation is an injectable, unit-testable
component that throws `RequestValidationFailureException` directly,
rather than a private static helper in the controller. No-arg
`deleteAll()` is sealed alongside the four key-addressed deletes, with
teardown moved to a test fixture. View metrics are owned by housetables;
the pre-existing `HTS_`/`REPO_` constants stay in `services/common`
because `services/tables` consumes 19 of them.

## Footprint on shared code

```
CorruptEntityTypeException.java   +19   (new)
OpenHouseExceptionHandler.java    +22   (one @hidden handler, 0 deletions)
```

`ServiceAuditAspect` and every other file in `services/common` are
byte-identical to base. ORM imports added to `services/common`: **0**
(#697 adds 3). The translation mechanism itself — a small
`CorruptEntityTypeTranslation` utility and the aspect that applies it —
lives in `services/housetables`, the module that already owns JPA.

## Deliberate asymmetries

Two remain, both in signatures rather than behaviour: `getUserTable`
signals absence by exception while the new point reads return
`Optional`, and table queries take the transport `UserTable` while view
queries take an owned type.

Both were measured rather than assumed. Unifying the absence convention
costs 16 pre-existing test edits and risks a silent wire change — the
current 404 carries `cause: null` because it wraps a message-less
`NoSuchElementException`, and no test pins that field. Unifying the
query vocabulary means rewriting 11 private query helpers and 3 branch
predicates that encode the frozen empty-filter behaviour. Both are worth
doing on their own terms, with their own review; neither has
view-specific content.

## Not adopted

- **A generic corruption 500 body.** Would make it the only
differently-shaped 500 in the service.
- **Removing the `.stacktrace(...)` sites from the shared handler.**
These do not leak: `ServiceAuditAspect` already strips the stack trace
from the client body and keeps it only in the internal audit event, so
deleting the sites would degrade the audit trail without changing any
client response. The populate-then-strip indirection is real, but it is
a `services/common` contract shared by tables, jobs and housetables — a
whole-platform refactor, not a views concern. The views-specific
error-handling improvement Mike's review asked for was instead made
through the corruption path (`CorruptEntityTypeException` + the
repository aspect), which gives a corrupt read a proper diagnostic 500
rather than raw ORM vocabulary.
- **The soft-delete version TOCTOU.** Pre-existing; needs a
version-guarded delete and its own interleaving test, so tracked
separately rather than folded into a views PR.

The production collation, an open question across both PR stacks, is now
confirmed `utf8mb4_0900_ai_ci` — accent-insensitive and NO PAD. This
needs no code change: it means a corrupt `'TÁBLE'` matches the type
predicate and fails hydration with a diagnostic 500, and a corrupt
`'TABLE '` is invisible to the typed routes. Both require a direct
database write, since the API writes only canonical enum names, so
neither is a reachable state.

## Testing

| Module | Tests | Failures |
|---|---|---|
| `services:housetables` | 404 | 0 |
| `services:common` | 12 | 0 |
| `services:tables` | 526 | 0 |
| `services:jobs` | 52 | 0 |

`tables` (526) and `jobs` (52) match their base counts after this branch
was rebased onto the current base. `:client:hts` regenerates cleanly
with **five** new operations — `getEntity`, `getUserView`,
`getPaginatedUserViews`, `putUserView`, `deleteView` (no non-paginated
`getUserViews`). The generated OpenAPI response sets match #697's for
every route that still exists, including a documented `500` on the three
GET routes that can reach a corrupt row (`/hts/entities`, `/hts/views`,
`/v1/hts/views/query`).

The change was also driven through #698's deployment e2e suite against a
real MySQL 8.4 container: **23 passed, 0 skipped**. That suite was
written against #697's API; the only edit needed was to route its
view-listing through the paginated endpoint, since the non-paginated one
is intentionally gone — every other assertion passed unchanged, which is
independent evidence the rest of the contract is indistinguishable to a
client. The confirmed production collation (`utf8mb4_0900_ai_ci`) is
exercised by that run.

Pre-existing tests were changed only where the frozen contract or an
adopted decision forced it, and each was strengthened rather than
weakened; the three teardown edits come from sealing `deleteAll()`. No
assertion was deleted or relaxed.

## A note on a pre-existing flake

`UserTablesServiceTest.testUserTablePurge` failed twice under concurrent
multi-module load and passed 6/6 in isolation.
`SoftDeletedUserTablesMapper` derives `deletedAtMs` from
`Instant.now()`, and that field is part of the soft-deleted composite
key; the test soft-deletes the same table twice and expects two rows, so
two calls in the same millisecond upsert into one. The mapper, the key
and that test are untouched here, and #697 carries identical code.

## Review process

Design-first: an implementation plan reviewed independently over two
rounds, tests written before the production logic and reviewed against
it, then an implementation review, followed by several rounds of
simplification that removed roughly 1,400 lines of machinery in favour
of the smallest thing that holds the contract. Load-bearing mechanics
were verified empirically rather than assumed — most importantly the
aspect's interception point, proven against Spring 5.3.25 by dumping the
nested proxy chains: the aspect sits in a separate outer proxy from
Spring Data's exception-translation interceptor, so no `@Order` value
could move it, and a negative control (aspect disabled) confirmed the
corrupt-row tests genuinely depend on it.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ruolin59 and others added 2 commits September 21, 2026 11:33
The suite in services/housetables drives the slice through MockMvc on H2.
That leaves untested the parts which only exist in a deployment: the JSON
a caller actually receives, the exception advice that has to survive
persistence wrapping, and the discriminator column as MySQL stores it.

These tests drive the running service over HTTP. Where the REST API cannot
express the state under test they plant rows with SQL, since no endpoint
writes a legacy NULL discriminator or a corrupt one. Those tests skip when
no database is reachable rather than fail, so the suite still runs against
a deployment whose database is not exposed.

CI moves from the oh-only recipe to a MySQL-backed variant of it. That
costs one container per run and changes what the existing integration test
runs against. oh-only itself is untouched, so the IN_MEMORY path keeps its
coverage. The recipe mounts services/housetables/ddl as the database's
init directory, so every run also checks that the recorded DDL is a
working migration path, which nothing else does today.

The fixed sleep before the tests becomes a readiness poll, because MySQL
starts more slowly and less predictably than H2.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use the v1 view listing, verify recorded MySQL DDL and mixed-case identifiers, wait for initialized MySQL before HTS startup, and require database coverage in CI.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@ruolin59
ruolin59 changed the base branch from rufan/views-hts-lifecycle to main September 21, 2026 18:51
@ruolin59
ruolin59 marked this pull request as ready for review September 21, 2026 18:57
Reuse the MySQL Compose recipe with a disposable project, dynamic loopback ports and temporary Python environment. Build the services, wait for readiness, run the existing suites and clean up. Document the entrypoint in README.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@ruolin59
ruolin59 force-pushed the rufan/hts-deployment-tests branch from 6668e0b to 8b3e2f5 Compare September 21, 2026 19:24
List the two HTTP suites and distinguish them from client SDK, Spark integration and repository-wide tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@ruolin59

Copy link
Copy Markdown
Collaborator Author

Superseded by #769, which preserves the deployment tests, adds production-DDL index regression coverage, and is rebased onto the prerequisite fix in #763.

@ruolin59 ruolin59 closed this Sep 24, 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